diff --git a/README.md b/README.md index dba90eb6..d8c13d96 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![stars](https://img.shields.io/github/stars/db-lyon/ue-mcp)](https://github.com/db-lyon/ue-mcp/stargazers) [![MIT](https://img.shields.io/npm/l/ue-mcp)](LICENSE) -**Unreal Engine Model Context Protocol Server** - gives AI assistants deep read/write access to the Unreal Editor through 24 category tools covering 783+ actions, plus a YAML flow engine for multi-step workflows and an npm plugin system for extending the surface. +**Unreal Engine Model Context Protocol Server** - gives AI assistants deep read/write access to the Unreal Editor through 24 category tools covering 790+ actions, plus a YAML flow engine for multi-step workflows and an npm plugin system for extending the surface. On UE 5.8+ it also wraps Epic's entire native AI Toolset Registry - 830 official Unreal tools, called in-process and surfaced as `epic_*` actions in the matching category: Sequencer in `animation`, PCG in `pcg`, static meshes in `asset`. @@ -88,7 +88,7 @@ Nothing is pre-authored. Every asset in the scene is created by the agent at run | **Blueprints** | Read/write graphs, add nodes, connect pins, compile, CDO and component property access | | **Materials** | Create materials and instances, author expression graphs, set parameters | | **Assets** | CRUD, import meshes/textures/animations, datatables, mesh bounds/collision/nav | -| **Animation** | Anim blueprints, montages, blendspaces, skeletons | +| **Animation** | Anim blueprints, montages, retargeting, native Control Rig editing, deterministic pose analysis | | **VFX** | Niagara systems, emitters, modules, renderers, parameters | | **Landscape** | Sculpt terrain, paint weight layers, materials, splines, proxies | | **Foliage** | Painting, foliage types, instance queries | @@ -137,7 +137,7 @@ flows: flow(action="run", flowName="build_and_check") ``` -Every one of the 783+ actions is also a flow task. Flows support step references, retries, rollback, custom tasks in your own `.js`/`.ts`, and shell steps. See [Flows](https://ue-mcp.com/docs/flows/). +Every one of the 790+ actions is also a flow task. Flows support step references, retries, rollback, custom tasks in your own `.js`/`.ts`, and shell steps. See [Flows](https://ue-mcp.com/docs/flows/). ## Plugins @@ -156,6 +156,7 @@ The package ships skills that teach agents the non-obvious parts of driving the | Skill | Covers | |-------|--------| | `ue-mcp-workflow` | Required order of operations, editor lifecycle, project scoping | +| [`ue-mcp-animation`](skills/ue-mcp-animation/SKILL.md) | UE 5.8 IK/retarget authoring, per-rig Control Rig solving, generic contact locks, bake and deterministic V&V | | `ue-mcp-blueprint` | Graph authoring, node discovery, pin wiring, compile loops | | `ue-mcp-niagara` | Emitter/module stack authoring and renderer setup | | `ue-mcp-native-cpp` | Writing and building native C++ against the bridge | diff --git a/docs/architecture.md b/docs/architecture.md index 2529cf82..f7f198b0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ The plugin runs a raw WebSocket server on a dedicated thread, dispatches incomin ### Handler Categories -28 C++ handler groups are registered in `BridgeServer.cpp`. Together they expose 783+ method names (some of which are aliases mapped onto a smaller number of canonical handlers): +28 C++ handler groups are registered in `BridgeServer.cpp`. Together they expose 790+ method names (some of which are aliases mapped onto a smaller number of canonical handlers): | Handler group | Coverage | |---------|----------| diff --git a/docs/control-rig-animation.md b/docs/control-rig-animation.md new file mode 100644 index 00000000..7d497e4d --- /dev/null +++ b/docs/control-rig-animation.md @@ -0,0 +1,485 @@ +# Native Control Rig Animation + +UE-MCP can author and modify animation through Unreal's Control Rig and +Sequencer APIs. The reliable workflow is data first: describe the pose as +component-space anatomical constraints, discover how the selected rig reaches +those constraints, write normalized quaternion keys, then validate the baked +bones and capture a few exact frames for human review. + +Do not reuse control names, Euler angles, or axis signs from another skeleton. +Those are rig-specific observations, not an animation recipe. + +The npm package ships the same operating rules as the +[`ue-mcp-animation` agent skill](https://github.com/db-lyon/ue-mcp/blob/main/skills/ue-mcp-animation/SKILL.md), +so `ue-mcp init` can install them with the other bundled workflow skills. + +## Compatibility + +| Capability | Engine support | +|------------|----------------| +| `begin_control_rig_edit`, `read_control_rig_edit`, `apply_control_rig_edits`, `bake_control_rig_edit` | UE 5.8 only; older engines return `unsupported_engine_version` and do not fall back to raw bone tracks | +| `analyze_animation` | Cross-version; it uses the native animation APIs available in the engine against which the bridge was compiled | +| `configure_ik_rig`, `configure_ik_retargeter`, and `contact_lock` inside `apply_control_rig_edits` | UE 5.8 only; older engines return `unsupported_engine_version` | +| Legacy IK Rig and IK Retargeter create/read actions | See each action in the [tool reference](tool-reference.md); their older-engine support is unchanged | + +The editing loop is native. `execute_python` and Computer Use are not part of +it. If a required datum is missing, add a native UE-MCP read or write action +instead of making an escape hatch part of the workflow. + +## Author IK and retarget context + +Control Rig edits operate most predictably when the selected skeleton already +has explicit chains, goals, and a solver. On UE 5.8, configure an existing IK +Rig with `configure_ik_rig` rather than writing reflected asset properties: + +```text +animation( + action="configure_ik_rig", + rigPath="/Game/Rigs/IK_", + retargetRoot="pelvis", + rootMotionBone="root", + chains=[...], + fullBodyIK={rootBone:"pelvis", goals:[...]} +) +``` + +The operation validates every named bone, chain ancestry, goal, solver +connection, and numeric setting before saving. `read_ik_rig` is the required +round-trip check: verify its preview mesh, roots, chains and goal assignments, +goals, exclusions, solver stack, and full-body effector settings. Do not treat +a chain carrying a goal *name* as proof that the goal or solver exists. + +Configure retargeting with `configure_ik_retargeter`: + +```text +animation( + action="configure_ik_retargeter", + retargeterPath="/Game/Rigs/RTG__", + sourceRig="/Game/Rigs/IK_", + targetRig="/Game/Rigs/IK_", + sourcePreviewMesh="/Game/Meshes/SK_", + targetPreviewMesh="/Game/Meshes/SK_", + ensureDefaultOps=true, + autoMapMode="exact", + forceRemap=true, + chainMappings=[...], + pose={side:"target", name:"", create:true, autoAlign:"chain_to_chain", rotationOffsets:[...]} +) +``` + +The UE 5.8 operation stack must receive both rigs; setting only the top-level +retargeter reference is insufficient. Auto-map first, apply deliberate manual +overrides second, then create and adjust a named pose. A whole-pose auto-align +resets that pose before the manual rotation offsets are applied. Read the +retargeter back and verify its per-operation rig assignments and mappings, +current/named pose deltas, preview meshes, and processor validation messages. + +Partial mapping is valid when the target owns extra twist, metacarpal, or +accessory chains. Record the intentionally unmapped chains and require all +motion-critical chains instead of blindly requiring every target chain. + +## The authoring loop + +### 1. Establish a per-character Control Rig baseline + +`begin_control_rig_edit` edits through a Control Rig; it does not invent one. +Before authoring the first clip for a new project or character, search for an +existing rig bound to the target mesh/skeleton. Inspect it with +`read_control_rig_hierarchy` and `read_control_rig_graph`. A usable baseline +must expose the controls needed by the intended motion, a Forward Solve that +drives the bones, and a Backward Solve that can initialize those controls from +source animation without a pose jump. + +If no suitable rig exists, create that baseline first. UE 5.8's bundled +Control Rig toolset is available through the animation category: start with +`epic_create`, import the production skeleton with +`epic_import_bones_from_asset`, add the intended controls with +`epic_add_control`, and add inverse initialization with +`epic_add_backward_solve_graph`; then use the remaining Control Rig graph, +node, and link actions and save the exact rig with `asset(epic_save_assets)`. +`epic_create` alone creates no imported bones, authored controls, or solver +wiring. +Build only the controls and solvers required by the character's current +animation work. Verify the saved hierarchy and graph, then round-trip an +unchanged source clip through Backward Solve, Forward Solve, and bake. Bone +transforms must remain within the project's tolerance before the rig is trusted +for production edits. + +Do not treat matching bone or control names as compatibility proof. Do not +create a new rig per animation; the verified character rig is the reusable +authoring foundation and baked AnimSequences remain the runtime output. + +### 2. Orient and create an immutable session + +Start with `project(action="get_status")` and verify the active project and +editor connection. Resolve the source AnimSequence, skeletal mesh, skeleton, +and Control Rig before writing. + +Create a versioned LevelSequence and binding tag: + +```text +animation( + action="begin_control_rig_edit", + sequencePath="/Game/AnimationWork/LS__V001", + skeletalMeshPath="/Game/", + sourceAnimationPath="/Game/", + rigMode="asset", + controlRigPath="/Game/", + layered=false, + startFrame=0, + endFrame=, + displayRate=, + bindingTag=".v001", + onConflict="error" +) +``` + +Use `rigMode="fk"` only when Unreal's generated `UFKControlRig` is the intended +editing surface. Use `rigMode="asset"` for a project's authored Control Rig. +`startFrame` is inclusive and `endFrame` is exclusive, so `[0, 91)` permits +keys at frames 0 through 90. + +The source must be a non-additive AnimSequence compatible with the selected +mesh. Flatten an additive clip against its intended base pose first. The +session's `layered` option controls whether the source track remains active +under the Control Rig layer; it does not supply an additive source's base pose. +Sequencer compensates a finite non-zero AnimSequence `RateScale`, so the session +maps the raw source timeline from start to end exactly once without changing the +source asset. A zero `RateScale` is rejected because it has no invertible time +mapping. + +Every material iteration gets a new session path, binding tag, and eventual +output path. Keep `onConflict="error"` while developing. `skip` is suitable +only for a deliberately idempotent replay; begin and bake never overwrite an +existing asset. + +### 3. Read controls before choosing them + +Call `read_control_rig_edit` on the candidate controls at the rest, transition, +peak, opposite peak, and final frames. Read both spaces: + +```text +animation(action="read_control_rig_edit", sequencePath=, + bindingTag=, controlNames=[...], frames=[...], space="local") +animation(action="read_control_rig_edit", sequencePath=, + bindingTag=, controlNames=[...], frames=[...], space="global") +``` + +`local` is relative to the control's rig parent. `global` is the Control Rig +hierarchy's global space, which normally corresponds to skeletal-mesh +component space. It is not actor world space. World-space review must also +compose the skeletal mesh component or actor transform. + +Control metadata reports: + +- `controlType`, `transformControl`, and `animatable` +- `enumName`, `enumPath`, and `enumOptions` with each option's name, display + name, and integer value +- transform samples, or typed bool, float/scale-float, integer, and enum values + +Never write a control with `animatable=false`. Use `set_bool`, `set_float`, or +`set_int` for scalar controls. For an enum, pass an exact integer listed in +`enumOptions`; do not infer it from the option's position or label. + +### 4. Solve anatomy in component space + +Define observable targets before changing controls. For an arm gesture, solve +proximal to distal: + +1. **Shoulder and upper arm:** place the arm's reach and elevation without + collapsing the shoulder into the torso. +2. **Elbow:** treat the elbow as a pole target. Preserve a plausible bend and a + stable elbow plane; do not let the elbow flip between frames. +3. **Forearm and wrist:** make the elbow-to-wrist vector point where the action + requires. A wave, for example, needs the forearm to rise rather than merely + moving the upper arm sideways. +4. **Palm:** align a rig-discovered palm normal toward the intended viewer or + interaction target. Establish which local hand axis represents that normal + from data; names such as X, Y, or Z are not anatomical facts. +5. **Secondary motion:** add the wrist oscillation only after the raised pose is + correct. Ease the entry and exit, and preserve fingers unless the gesture + explicitly needs them changed. + +IK is useful when the hand target and elbow pole are exposed and well behaved. +FK is valid when those controls are absent or their orientation contract is +unclear, but still judge the result using component-space bones and landmarks. +Do not tune a wrist control in isolation and assume the arm chain followed. + +### What transfers to other rigs and motions + +The loop transfers; the rig mapping does not. Keep the same discover, constrain, +solve, key, bake, and validate stages, then choose landmarks suited to the +motion: + +- Legs use pelvis/hip placement, a knee pole, a foot target and orientation, + ground contact, and foot-slip measurements. +- Spine, neck, and head motion uses a component-space arc, twist distribution, + and an aim or gaze direction with per-joint limits. +- Tails, tentacles, ropes, and other chains use a target curve, segment-length + preservation, bend limits, and phase-delayed keys along the chain. +- Prop, socket, and mechanical animation uses pivot axes, attachment transforms, + contact/clearance constraints, and the same quaternion continuity checks. +- A retargeted character repeats target-side control, axis, scale, and limit + discovery before edits; source-rig constants are not portable evidence. + +This makes the method reusable across skeletons and animated hierarchies while +keeping the only unavoidable custom data small: control/bone mapping, local +axes and signs, mirrored scale, joint limits, and the motion's constraints. + +### 5. Probe mirrored and ambiguous axes + +Never obtain right-side rotations by negating left-side Euler values. A +right-side control may inherit mirrored axes or negative scale, so the same +local rotation can have a different anatomical meaning. + +For every unfamiliar rig, and separately for each side when needed: + +1. Read the relevant controls in local and global space, preserving their full + translation, rotation, and scale. Use `get_bone_transforms` in local and + component space for reference-pose chain orientation. +2. In a disposable versioned session, apply a small positive and negative + rotation around one local axis at one fixed frame. Change only one axis per + probe. +3. Bake and run `analyze_animation` for the shoulder, upper arm, forearm, hand, + and a palm/finger landmark. Record the observed component-space movement. +4. Build the axis/sign mapping for that rig and side. Preserve any negative + scale from the read transform. Repeat the probe after changing rigs or + retargeting to a materially different hierarchy. + +This small probe is cheaper and safer than correcting a full clip built on an +assumed axis convention. + +### 6. Apply absolute quaternion keys + +Use `set_keys` for reproducible transform authoring: + +```json +{ + "op": "set_keys", + "control": "", + "space": "local", + "keys": [ + { + "frame": 12, + "transform": { + "translation": { "x": 0, "y": 0, "z": 0 }, + "rotationQuaternion": { "x": 0, "y": 0, "z": 0, "w": 1 }, + "scale": { "x": 1, "y": 1, "z": 1 } + } + } + ] +} +``` + +The numbers above illustrate the payload shape only; read the real base +transform first. The rules are: + +- Keys are full, absolute transforms with strictly increasing, unique frames. +- Quaternions must be finite and normalized. UE-MCP preserves shortest-arc + continuity; use quaternions rather than interpolating Euler angles. +- Preserve read-back translation and scale unless the motion intentionally + changes them. This is especially important for mirrored/negative-scale + controls. +- A source animation baked into Control Rig may already contain a key on every + frame. In that case, write the edited control on every frame of the affected + interval; sparse keys do not replace intervening source keys. +- Apply related controls and scalar switches in one + `apply_control_rig_edits` call. The batch is prevalidated, transacted, + read back, and undone if application or readback fails. + +Read the same frames again after applying. Check both local continuity and the +global/component anatomical targets before baking. + +### Contact constraints + +Use `contact_lock` when a known Control Rig driver must hold a control, bone, or +socket at a fixed mesh-component-space transform across an inclusive interval: + +```json +{ + "op": "contact_lock", + "control": "", + "drivenReference": "", + "startFrame": 12, + "endFrame": 38, + "target": { + "translation": { "x": 0, "y": 0, "z": 0 }, + "rotationQuaternion": { "x": 0, "y": 0, "z": 0, "w": 1 } + }, + "blendInFrames": 4, + "blendOutFrames": 4, + "stabilizeControls": [""], + "positionToleranceCm": 0.1, + "rotationToleranceDegrees": 0.5 +} +``` + +Read the target from the source or session; the identity values above show only +the payload shape. The session must contain one source skeletal-animation +section. The bridge samples that AnimSequence at each mapped Sequencer frame, +measures the bone/socket offset from the driver, and solves dense +component-space driver keys. Smooth edge weights blend into and out of the +lock, and at least one frame must remain fully constrained. + +FK bones commonly use skeleton translation retargeting, which discards their +authored translation keys during AnimSequence playback. For an FK +`drivenReference` contact on such a bone, the bridge instead resolves the +driver-to-reference descendant chain, runs a per-frame FABRIK position solve, +and writes local rotation-chain keys while preserving the source local bone +translations. The result reports `solver=fk_rotation_chain`. This special FK +path requires a driven bone and does not accept stabilizer controls; use an +asset Control Rig when the contact targets a socket or also needs a pole or +secondary stabilizer. A translation-only target leaves the driven control's +orientation unkeyed; the end control joins the keyed chain only when +`target.rotationQuaternion` is supplied. + +The apply call transactionally reads back the driver and stabilizer keys. With +`drivenReference`, `contactQa.verification` is +`bake_and_analyze_required`: the composed layered bone/socket result is not +claimed before export. Bake to a new AnimSequence, run `analyze_animation` on +every constrained frame, and compare the driven reference with the target. If +it exceeds the motion's acceptance tolerance, reject that output and revise the +driver, stabilizers, or rig mapping. Without `drivenReference`, the driver +itself is constrained and its residual is checked during apply. + +This is deliberately a generic contact primitive, not a foot-specific macro. +It works for hands on props, planted feet, held tools, mechanical linkages, and +other contacts when the rig exposes a driver with the required degrees of +freedom. The caller still discovers the correct driver and optional +pole/stabilizer, handles foot roll or pelvis compensation when the rig needs +them, and verifies the baked affected and unaffected bones. It does not model +collision, friction, joint limits, foot roll, or pelvis compensation. + +### 7. Bake to a new asset + +```text +animation( + action="bake_control_rig_edit", + sequencePath=, + bindingTag=, + outputAssetPath="/Game/AnimationWork/A__V001", + frameRate=, + reduceKeys=false, + createLink=false, + onConflict="error" +) +``` + +The source animation and LevelSequence are not overwritten. Key reduction and +Sequencer/AnimSequence links are not supported by this workflow, so omit +`reduceKeys` and `createLink` or pass `false`. + +## Deterministic validation + +Run `analyze_animation` on both the source and baked output with the same mesh, +bones, and frames: + +```text +animation( + action="analyze_animation", + assetPath=, + skeletalMeshPath=, + boneNames=[, , , , ], + frames=[...], + loop=false, + outputDirectory="/v001" +) +``` + +The native result and optional `manifest.json` / `samples.ndjson` contain exact +local and component transforms. The built-in summary reports numeric integrity, +invalid-transform count, root displacement and maximum root speed, selected-bone +bounds, and optional loop-seam root/joint errors. The manifest also records +centimeter units and Unreal's +X forward, +Y right, +Z up convention. + +Timing metadata is explicit: `durationSeconds` is the raw sequence duration, +`rateScale` is the AnimSequence asset rate, and `effectiveDurationSeconds` is +the raw duration divided by the rate magnitude. Each `notifies` record includes +`rawTriggerTimeSeconds` and its rate-scaled `effectiveTriggerTimeSeconds`. A +zero asset rate reports null effective times because playback does not advance. + +Derive motion-specific assertions from the samples rather than screenshots: + +- wrist height relative to shoulder and the elbow-to-wrist direction +- elbow angle, elbow-plane change, and per-frame joint angular change +- palm-normal alignment using the axis established by the probe +- wrist speed/acceleration and deliberate wave direction changes +- root, feet, opposite side, fingers, and other supposedly untouched bones + remaining within the fixture's tolerance of the source + +Always inspect the first frame, transition frames, extrema, last visible frame, +and any frame with the largest numeric delta. Screenshots are review evidence; +the sampled transforms are the source of truth. + +### Exact fixed-frame Unreal capture + +Capture through native UE-MCP calls, without Computer Use or Python: + +1. `editor(action="open_asset", assetPath=)`. +2. `editor(action="find_object", className="AnimSingleNodeInstance", + nameContains="AnimPreviewInstance", world="any")`. If several objects are + returned, choose the one under the current `AnimationEditorPreviewActor` + (or constrain `outerPath`) rather than a stale preview instance. +3. Freeze and seek atomically with `editor(action="invoke_object_functions")`: + + ```json + { + "calls": [ + { + "objectPath": "", + "functionName": "SetPlaying", + "args": { "bIsPlaying": false } + }, + { + "objectPath": "", + "functionName": "SetPosition", + "args": { + "InPosition": 1.1333333333, + "bFireNotifies": false + } + } + ] + } + ``` + + Compute `InPosition` exactly as `frame * rateDenominator / rateNumerator`; + the decimal above is only an example payload. +4. Open the asset again to focus its Slate window, then call + `editor(action="capture_screenshot", target="window", filename=)`. +5. Repeat the same camera and window setup for start, raised pose, both motion + extrema, and end. Keep clean images beside the numeric analysis artifacts. + +## V&V fixtures + +Before treating a rig or workflow as production-ready, retain these small, +versioned fixtures: + +| Fixture | Proves | +|---------|--------| +| From-scratch gesture over a neutral source pose | Per-rig discovery, typed controls, dense quaternion keys, bake, numeric checks, and fixed-frame review | +| Retarget between two known skeletons | Chain mapping, retarget pose, root behavior, proportions, and target-side axis discovery | +| Copy an existing animation, switch/use IK, then modify the hand and elbow target | IK/FK scalar handling, endpoint and pole control, and preservation of unedited motion | +| Full-body IK authoring from an existing mesh | Roots, ancestry-valid chains, concrete goals, solver/effector connections, save/reload round trip | +| Bone/socket contact plus a simultaneous unrelated edit | Generic dense constraint solving, smooth transitions, transactional key readback, post-bake residual QA, and preservation outside the constraint | +| Edge-case pack | Right-side mirroring or negative scale, dense source keys, additive-source rejection/flattening, layered sessions, scalar enums/floats, root motion, loop seams, and short clips | + +Each fixture records source/session/output asset paths, binding tag, engine +version, selected controls and metadata, probe observations, sampled frames and +bones, numeric acceptance checks, and clean screenshots. Human approval is the +final visual gate, not a replacement for those records. + +## Exact-duration endpoint gate + +Control Rig edit ranges are end-exclusive: the last authorable frame is +`endFrameExclusive - 1`. A baked AnimSequence can additionally expose a sample +at its exact duration (`sourceFrameCount`) even though the animation editor's +last visible authored frame is one frame earlier. The bridge keeps an internal +evaluation support frame so Unreal's exporter cannot sample outside the source +and Control Rig sections; the exclusive end is still not authorable. + +Validate the last visible authored frame and exact-duration sample separately. +For a non-looping clip, the endpoint must hold the intended final pose within +the fixture tolerance and must not introduce an adjacent-frame teleport or +rotation jump. For a loop, pass `loop=true` and require the endpoint to satisfy +the intended seam. Treat a large endpoint discontinuity as a failed bake, not +as an Unreal sampling quirk. diff --git a/docs/flows.md b/docs/flows.md index e97b50b5..219c4aac 100644 --- a/docs/flows.md +++ b/docs/flows.md @@ -36,7 +36,7 @@ The response carries a `summary` line per step plus a `steps` array holding what ### Tasks -A task is a named unit of work. UE-MCP ships with **783+ built-in tasks** across 24 categories - every action available through the MCP tools is also a flow task. +A task is a named unit of work. UE-MCP ships with **790+ built-in tasks** across 24 categories - every action available through the MCP tools is also a flow task. Tasks are defined in the `tasks:` section of your config: @@ -59,7 +59,7 @@ The fields: | `group` | No | Category for organization | | `options` | No | Default options passed to the task (can be overridden per-step) | -You rarely need to define tasks yourself - the built-in defaults cover all 783+ actions. You define tasks when you want to **override** or **add** custom ones. +You rarely need to define tasks yourself - the built-in defaults cover all 790+ actions. You define tasks when you want to **override** or **add** custom ones. ### Flows @@ -633,7 +633,7 @@ Configuration is loaded with [`@db-lyon/flowkit`'s config loader](https://github | Layer | File | Purpose | |-------|------|---------| -| 1 (base) | Built-in defaults | All 783+ tasks, no flows | +| 1 (base) | Built-in defaults | All 790+ tasks, no flows | | 2 | `ue-mcp.yml` | Your project config | | 3 | `ue-mcp.{env}.yml` | Environment overlay (set `UE_MCP_ENV`) | | 4 | `ue-mcp.local.yml` | Local-only overrides (gitignore this) | diff --git a/docs/index.md b/docs/index.md index dca2e227..d788e750 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # UE-MCP -**Unreal Engine Model Context Protocol Server** - lets AI assistants drive the Unreal Editor through 24 category tools covering 783+ actions. +**Unreal Engine Model Context Protocol Server** - lets AI assistants drive the Unreal Editor through 24 category tools covering 790+ actions. UE-MCP is a bridge between an AI client (Claude Code, Claude Desktop, Cursor, etc.) and the Unreal Editor. It runs a TypeScript MCP server on your machine, which talks over WebSocket to a C++ plugin running inside the editor. The result: your AI can place actors, write blueprints, author materials, sculpt landscape, set up Niagara VFX, configure replication, run PIE, build the project - anything the editor can do. @@ -26,7 +26,7 @@ Start with **[Getting Started](getting-started.md)**. It assumes zero knowledge | **Blueprints** | Read/write graphs, add nodes, connect pins, compile, CDO property access | | **Materials** | Create materials and instances, author expression graphs, set parameters | | **Assets** | CRUD, import meshes/textures/animations, datatables | -| **Animation** | Read/create anim blueprints, montages, blendspaces, skeletons | +| **Animation** | Anim blueprints, montages, retargeting, native Control Rig editing, deterministic pose analysis | | **VFX** | Create and configure Niagara systems and emitters | | **Landscape** | Sculpt terrain, paint weight layers, materials, splines, proxies | | **PCG** | Author and execute Procedural Content Generation graphs | @@ -54,6 +54,7 @@ And a **plugin system** that lets npm packages inject new actions into the exist - **[Getting Started](getting-started.md)** - Zero-to-running walkthrough for first-time users - **[Architecture](architecture.md)** - How the TypeScript server, C++ plugin, and editor fit together - **[Tool Reference](tool-reference.md)** - All 24 tools with every action and its parameters +- **[Native Control Rig Animation](control-rig-animation.md)** - Agent workflow for rig discovery, anatomical solving, quaternion keys, baking, and deterministic V&V - **[Flows](flows.md)** - Multi-step YAML workflows, custom tasks, hooks, rollback - **[Plugins](plugins.md)** - npm packages that inject new actions into existing categories - **[Configuration](configuration.md)** - `ue-mcp.yml` and MCP client config diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 214bb97c..9cd1a2a7 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -2,7 +2,7 @@ This page lists ue-mcp's own category tools and actions. For the official Unreal 5.8 tools that ue-mcp wraps (surfaced inside these same categories), see [Native Tools](native-tools.md). -UE-MCP exposes **24 category tools** covering **783+ actions**, plus a `flow` tool for running multi-step YAML workflows. Every category tool takes an `action` parameter that selects the operation, plus action-specific parameters. +UE-MCP exposes **24 category tools** covering **790+ actions**, plus a `flow` tool for running multi-step YAML workflows. Every category tool takes an `action` parameter that selects the operation, plus action-specific parameters. !!! tip "First call in any session" Start with `project(action="get_status")` to check the connection, then `level(action="get_outliner")` or `asset(action="list")` to explore. @@ -428,22 +428,29 @@ UE-MCP exposes **24 category tools** covering | `remove_montage_segment` | Remove a segment from a montage slot by index, then relay out the remaining segments and rewrite the montage length. Idempotent: alreadyDeleted=true when the slot already holds no segments. Params: `assetPath, segmentIndex, slotName?, slotIndex? (default 0) (#826)` | | `list_montage_segments` | List every slot's segments on a montage so a caller can address them by index: animation path, startPos/endPos trim, playRate, loopCount, track position and length per segment, plus the sections with the slot and segment each one links to. Params: `assetPath, slotName? (filter to one slot) (#826)` | | `create_ik_rig` | Create IKRigDefinition asset, optionally with retargetRoot + chains[]. Params: `name, skeletalMeshPath, packagePath?, retargetRoot?, chains?: [{name, startBone, endBone, goal?}]` | -| `read_ik_rig` | Read IK Rig chains, solvers, skeleton. Params: `assetPath` | +| `read_ik_rig` | Read an IK Rig's preview mesh, skeleton roots/bones, ancestry-validated chains and goal assignments, concrete goals, exclusions, and structured solver/FBIK effector state. Params: `assetPath` | +| `configure_ik_rig` | UE 5.8 only. Author an existing IK Rig through UIKRigController with strict bone, ancestry, goal, and setting validation, native readback, one transaction, and checked save; older engines return unsupported_engine_version. autoSetup='retarget' installs the native retarget definition; 'full_body' installs the retarget definition then Full Body IK before requested desired-state upserts. Params: `rigPath, autoSetup? ('retarget'\|'full_body'), retargetRoot?, rootMotionBone?, chains?: [{name,startBone,endBone,goal?}], fullBodyIK?: {solverIndex?,rootBone,enabled?,goals:[{name,bone,positionAlpha?,rotationAlpha?,chainDepth?,strengthAlpha?,pullChainAlpha?,pinRotation?}]}, exclusions?: [{bone,excluded}]` | | `list_control_rig_variables` | List ControlRig variables and hierarchy. Params: `assetPath` | | `read_control_rig_graph` | Read a Control Rig's RigVM models: every graph with its nodes (name, node path, class), each node's pins (name, pin path, cppType, direction, execute flag, default value, nested sub-pins) and the links between them, plus full member-variable metadata (type, subtype, array-ness, default, public/read-only). list_control_rig_variables only ever reported a node COUNT, which is not enough to verify solver wiring (#774). Params: `assetPath, graphName? (substring filter), includePins? (default true), includeDefaults? (default true), includeLinks? (default true), limit? (nodes per graph, default 200)` | | `read_control_rig_hierarchy` | Read a Control Rig's per-element hierarchy metadata: each element's name, type (Bone\|Control\|Null\|Curve...), index, and parent. Params: `assetPath (#619)` | +| `begin_control_rig_edit` | UE 5.8 only. Create a Sequencer Control Rig editing session over a source AnimSequence; native returns unsupported_engine_version on older engines. Baseline first: before this call, reuse or create a Control Rig for the target character, bind/import the exact target skeleton, add the intended controls, author Forward Solve, add Backward/Inverse Solve, verify it with read_control_rig_hierarchy/read_control_rig_graph, and pass an unchanged source round-trip. For a new baseline, the bundled Epic 5.8 controlrig actions include epic_create, epic_import_bones_from_asset, epic_add_control, and epic_add_backward_solve_graph; epic_create alone is not a usable rig. There is no silent fallback to raw bone-key authoring. rigMode='fk' uses UFKControlRig only when generated FK controls are sufficient; rigMode='asset' requires the verified controlRigPath and rejects rigs without inverse execution. bindingTag is the stable natural key for replay. onConflict is skip\|error (default error); existing sessions are never modified. layered defaults false. startFrame is inclusive and endFrame is exclusive. Params: `sequencePath, skeletalMeshPath, sourceAnimationPath, rigMode ('fk'\|'asset'), controlRigPath?, layered?, startFrame?, endFrame?, displayRate?, bindingTag?, onConflict?` | +| `read_control_rig_edit` | UE 5.8 only. Read transform, bool, float/scale-float, and integer/enum controls from a Control Rig editing session without changing editor state; native returns unsupported_engine_version on older engines and has no silent fallback. Params: `sequencePath, bindingTag, controlNames?, frames?, space? ('local'\|'global')` | +| `apply_control_rig_edits` | UE 5.8 only. Apply typed Control Rig edits in one transaction; native returns unsupported_engine_version on older engines. There is no silent fallback to raw bone tracks. set_keys writes strictly ordered full per-frame transforms from normalized quaternions and preserves shortest-arc quaternion continuity. A set operation writes one full absolute transform at frame or frames. An offset operation applies translation/rotation/scale deltas across an inclusive frame range with optional edge blends. contact_lock densely constrains a translatable driver control, or an optional driven bone/socket reference, to a fixed component-space target with smooth edge blends and optional pole/control stabilization. Driver and stabilizer keys are read back transactionally. A drivenReference contact returns verification='bake_and_analyze_required'; bake it and analyze every constrained frame before accepting the bone/socket result. set_bool, set_float, and set_int key matching scalar controls; enum controls use set_int with one of the integer values reported in enumOptions. Params: `sequencePath, bindingTag, operations[] where set_keys={op:'set_keys',control,keys:[{frame,transform:{translation,rotationQuaternion,scale}}],space?}, set={op:'set',control,frame\|frames,transform:{translation,rotationDegrees,scale},space?}, offset={op:'offset',control,startFrame,endFrame,translationCm?,rotationDegrees?,scaleMultiplier?,space?,blendInFrames?,blendOutFrames?}, contact_lock={op:'contact_lock',control,drivenReference?,startFrame,endFrame,target:{translation,rotationQuaternion?},blendInFrames?,blendOutFrames?,stabilizeControls?,positionToleranceCm?,rotationToleranceDegrees?}, set_bool={op:'set_bool',control,frame\|frames,value}, set_float={op:'set_float',control,frame\|frames,value}, or set_int={op:'set_int',control,frame\|frames,value}` | +| `bake_control_rig_edit` | UE 5.8 only. Bake the evaluated Control Rig session to a new AnimSequence asset; native returns unsupported_engine_version on older engines and has no raw-track fallback. The source LevelSequence remains unchanged. outputAssetPath is the output natural key; onConflict is skip\|error (default error), never overwrite. Key reduction and Sequencer links are not supported yet, so reduceKeys/createLink must be false or omitted. Params: `sequencePath, bindingTag, outputAssetPath, frameRate?, reduceKeys?, tolerance?, createLink?, onConflict?` | +| `analyze_animation` | Cross-version, data-driven AnimSequence inspection using the native animation APIs available in the compiled engine. Samples an AnimSequence and reports deterministic numeric motion diagnostics without Python or viewport inference. Params: `assetPath (required AnimSequence), skeletalMeshPath?, boneNames?, frames?, sampleRate?, loop?, outputDirectory? (must resolve under Project/Saved/Codex/AnimationQA and must not already contain artifacts)` | | `set_root_motion` | Set root motion settings on AnimSequence. Params: `assetPath, enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?` | | `add_virtual_bone` | Add virtual bone. Params: `skeletonPath, sourceBone, targetBone` | | `remove_virtual_bone` | Remove virtual bone. Params: `skeletonPath, virtualBoneName` | | `create_composite` | Create AnimComposite. Params: `name, skeletonPath, packagePath?` | | `list_modifiers` | List applied animation modifiers. Params: `assetPath` | | `create_ik_retargeter` | Create IKRetargeter asset and (default) initialize the UE 5.7 ops stack: assigns sourceRig+targetRig to all ops, runs AutoMapChains. Returns chainsMapped count. Params: `name, packagePath?, sourceRig?, targetRig?, autoMapChains? (default true) (#246)` | -| `read_ik_retargeter` | Read IKRetargeter: source/target rigs and chain mappings. Params: `assetPath (#246)` | +| `read_ik_retargeter` | Read an IK Retargeter's source/target rigs and preview meshes, flattened and per-op chain mappings, typed op stack, and all named/current pose offsets when the compiled engine exposes them. Params: `assetPath (#246)` | +| `configure_ik_retargeter` | UE 5.8 only. Configure an existing IK Retargeter through UIKRetargeterController with the correct default-op and per-op rig assignment order, auto/manual chain mappings, named pose authoring, processor validation, native readback, transaction rollback, and checked save; older engines return unsupported_engine_version. Whole-pose auto-align resets that pose first: create a new pose or pass pose.reset=true to acknowledge replacement, then manual offsets are applied. Params: `retargeterPath, sourceRig?, targetRig?, sourcePreviewMesh?, targetPreviewMesh?, ensureDefaultOps? (default true), autoMapMode? ('exact'\|'fuzzy'\|'clear'), forceRemap? (default false), chainMappings?: [{targetChain,sourceChain?:string\|null}], pose?: {side,name,create?,reset?,autoAlign?,bones?,rotationOffsets?:[{bone,rotationQuaternion}],rootOffsetZ?,snapBoneToGround?}` | | `set_ik_rig_mesh` | Set the preview/source skeletal mesh on an EXISTING IK Rig. Params: `rigPath, meshPath (#701)` | | `set_ik_retargeter_rig` | Set the source or target IK Rig on an EXISTING IK Retargeter. Params: `retargeterPath, rigPath, side? (source\|target, default target) (#703)` | | `auto_align_retarget_pose` | Auto-align all bones of the source/target retarget pose (chain-to-chain) - fixes a retargeter that outputs a static reference pose. Params: `retargeterPath, side? (source\|target, default target) (#701)` | | `reset_retarget_pose` | Reset the current retarget pose (all bones) to the reference pose. Params: `retargeterPath, side? (source\|target, default target) (#701)` | -| `batch_retarget_animations` | Bake a set of source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget). Params: `retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (#701)` | +| `batch_retarget_animations` | Bake validated source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget), save every output, and roll back newly created outputs if the batch is incomplete or unsavable. Overwrite is rejected. Returns mapping completeness and every unmapped target chain so partial retargets are explicit; pass requireCompleteMapping=true only when the target should have no intentional extra chains. Params: `retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (must be false), requireCompleteMapping? (default false) (#701)` | | `set_anim_blueprint_skeleton` | Set target skeleton on AnimBP. Params: `assetPath, skeletonPath` | | `read_bone_track` | Read bone transform samples from AnimSequence. Params: `assetPath, boneName, frames?: [int]` | | `create_pose_search_database` | Create a PoseSearchDatabase asset (motion matching). Params: `name, packagePath?, schemaPath?` | diff --git a/mkdocs.yml b/mkdocs.yml index 5f6e7ffe..c42cabc9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - Getting Started: getting-started.md - Architecture: architecture.md - Tool Reference: tool-reference.md + - Control Rig Animation: control-rig-animation.md - Native Tools: native-tools.md - Handler Conventions: handler-conventions.md - Widgets: diff --git a/package.json b/package.json index c7be53c8..41de40c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ue-mcp", "version": "1.2.4", - "description": "Unreal Engine MCP server - 24 tools, 783+ actions for AI-driven editor control", + "description": "Unreal Engine MCP server - 24 tools, 790+ actions for AI-driven editor control", "type": "module", "main": "dist/index.js", "exports": { diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp index 6d626a53..def92e63 100644 --- a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp @@ -131,6 +131,7 @@ void FAnimationHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry) // IK Rig (#93) Registry.RegisterHandler(TEXT("create_ik_rig"), &CreateIKRig); Registry.RegisterHandler(TEXT("read_ik_rig"), &ReadIKRig); + Registry.RegisterHandler(TEXT("configure_ik_rig"), &ConfigureIKRig); // #701/#703: IK authoring tail + batch retarget. Registry.RegisterHandler(TEXT("set_ik_rig_mesh"), &SetIKRigMesh); Registry.RegisterHandler(TEXT("set_ik_retargeter_rig"), &SetIKRetargeterRig); @@ -142,6 +143,11 @@ void FAnimationHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry) Registry.RegisterHandler(TEXT("list_control_rig_variables"), &ListControlRigVariables); Registry.RegisterHandler(TEXT("read_control_rig_graph"), &ReadControlRigGraph); Registry.RegisterHandler(TEXT("read_control_rig_hierarchy"), &ReadControlRigHierarchy); + Registry.RegisterHandler(TEXT("begin_control_rig_edit"), &BeginControlRigEdit); + Registry.RegisterHandler(TEXT("read_control_rig_edit"), &ReadControlRigEdit); + Registry.RegisterHandler(TEXT("apply_control_rig_edits"), &ApplyControlRigEdits); + Registry.RegisterHandler(TEXT("bake_control_rig_edit"), &BakeControlRigEdit); + Registry.RegisterHandler(TEXT("analyze_animation"), &AnalyzeAnimation); // v0.7.11 - depth Registry.RegisterHandler(TEXT("set_root_motion_settings"), &SetRootMotionSettings); @@ -153,6 +159,7 @@ void FAnimationHandlers::RegisterHandlers(FMCPHandlerRegistry& Registry) // v0.7.11 - issue fixes Registry.RegisterHandler(TEXT("create_ik_retargeter"), &CreateIKRetargeter); Registry.RegisterHandler(TEXT("read_ik_retargeter"), &ReadIKRetargeter); + Registry.RegisterHandler(TEXT("configure_ik_retargeter"), &ConfigureIKRetargeter); Registry.RegisterHandler(TEXT("set_anim_blueprint_skeleton"), &SetAnimBlueprintSkeleton); Registry.RegisterHandler(TEXT("read_bone_track"), &ReadBoneTrack); diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h index fa98a63d..f4febadb 100644 --- a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.h @@ -96,6 +96,8 @@ class FAnimationHandlers // IK Rig (#93) static TSharedPtr CreateIKRig(const TSharedPtr& Params); static TSharedPtr ReadIKRig(const TSharedPtr& Params); + // UE 5.8 full-body IK definition authoring over an existing IK Rig. + static TSharedPtr ConfigureIKRig(const TSharedPtr& Params); // Control Rig (#11) static TSharedPtr ListControlRigVariables(const TSharedPtr& Params); @@ -104,6 +106,14 @@ class FAnimationHandlers // #619 per-element Control Rig hierarchy metadata (name, type, index, parent) static TSharedPtr ReadControlRigHierarchy(const TSharedPtr& Params); + // UE 5.8 Control Rig editing in Sequencer. Source AnimSequences are read-only; + // edits live in a LevelSequence until explicitly baked to a new AnimSequence. + static TSharedPtr BeginControlRigEdit(const TSharedPtr& Params); + static TSharedPtr ReadControlRigEdit(const TSharedPtr& Params); + static TSharedPtr ApplyControlRigEdits(const TSharedPtr& Params); + static TSharedPtr BakeControlRigEdit(const TSharedPtr& Params); + static TSharedPtr AnalyzeAnimation(const TSharedPtr& Params); + // v0.7.11 - depth static TSharedPtr SetRootMotionSettings(const TSharedPtr& Params); static TSharedPtr AddVirtualBone(const TSharedPtr& Params); @@ -114,6 +124,8 @@ class FAnimationHandlers // v0.7.11 - issue fixes static TSharedPtr CreateIKRetargeter(const TSharedPtr& Params); static TSharedPtr ReadIKRetargeter(const TSharedPtr& Params); + // UE 5.8 retarget op, chain-map, preview-mesh and retarget-pose authoring. + static TSharedPtr ConfigureIKRetargeter(const TSharedPtr& Params); // #701/#703: IK rig/retargeter authoring tail + batch retarget bake. static TSharedPtr SetIKRigMesh(const TSharedPtr& Params); static TSharedPtr SetIKRetargeterRig(const TSharedPtr& Params); diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp new file mode 100644 index 00000000..90b9b182 --- /dev/null +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp @@ -0,0 +1,3061 @@ +// UE 5.8 Control Rig editing through Sequencer. +// +// The LevelSequence is the mutable workspace. Input AnimSequences and Control +// Rig assets are never changed; bake always creates a separate AnimSequence. + +#include "AnimationHandlers.h" + +#include "HandlerUtils.h" + +namespace +{ + TSharedPtr ControlRigSequencerUnsupported() + { + auto Result = MakeShared(); + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("errorCode"), TEXT("unsupported_engine_version")); + Result->SetStringField(TEXT("error"), TEXT("Control Rig Sequencer authoring requires Unreal Engine 5.8 or newer")); + return MCPResult(Result); + } +} + +#if UE_MCP_HAS_5_8_API + +#include "HandlerAssetCreate.h" + +#include "Algo/Reverse.h" +#include "AnimPose.h" +#include "FABRIK.h" +#include "Animation/AnimSequence.h" +#include "Animation/Skeleton.h" +#include "Components/SkeletalMeshComponent.h" +#include "ControlRig.h" +#include "ControlRigSequencerEditorLibrary.h" +#include "EditorAssetLibrary.h" +#include "Editor.h" +#include "Engine/Blueprint.h" +#include "Engine/SkeletalMesh.h" +#include "Engine/SkeletalMeshSocket.h" +#include "Animation/SkeletalMeshActor.h" +#include "Exporters/AnimSeqExportOption.h" +#include "IControlRigObjectBinding.h" +#include "ILevelSequenceEditorToolkit.h" +#include "ISequencer.h" +#include "LevelSequence.h" +#include "LevelSequenceEditorBlueprintLibrary.h" +#include "Misc/PackageName.h" +#include "MovieScene.h" +#include "MovieSceneBindingProxy.h" +#include "MovieSceneObjectBindingID.h" +#include "MovieSceneSpawnable.h" +#if WITH_DEV_AUTOMATION_TESTS +#include "Misc/AutomationTest.h" +#endif +#include "Rigs/FKControlRig.h" +#include "Rigs/RigHierarchy.h" +#include "ScopedTransaction.h" +#include "Sections/MovieSceneSkeletalAnimationSection.h" +#include "Sequencer/MovieSceneControlRigParameterSection.h" +#include "Sequencer/MovieSceneControlRigParameterTrack.h" +#include "Tracks/MovieSceneSkeletalAnimationTrack.h" +#include "Tracks/MovieSceneSpawnTrack.h" +#include "Subsystems/AssetEditorSubsystem.h" +#include "Units/Execution/RigUnit_InverseExecution.h" + +namespace +{ + constexpr int32 ControlRigSequencerMaxFrames = 100000; + + struct FControlRigSequenceSession + { + ULevelSequence* Sequence = nullptr; + UMovieScene* MovieScene = nullptr; + FGuid BindingGuid; + UMovieSceneControlRigParameterTrack* Track = nullptr; + UMovieSceneControlRigParameterSection* Section = nullptr; + UControlRig* ControlRig = nullptr; + FString SequencePath; + FString BindingTag; + }; + + struct FControlRigTransformPatch + { + bool bTranslation = false; + bool bRotation = false; + bool bScale = false; + FVector Translation = FVector::ZeroVector; + FQuat Rotation = FQuat::Identity; + FVector Scale = FVector::OneVector; + }; + + enum class EControlRigPreparedValueType : uint8 + { + Transform, + Bool, + Float, + Integer, + }; + + struct FControlRigPreparedWrite + { + FName Control; + TArray Frames; + TArray Before; + TArray After; + EControlRigTransformSpace Space = EControlRigTransformSpace::Local; + FString Op; + EControlRigPreparedValueType ValueType = EControlRigPreparedValueType::Transform; + bool BoolValue = false; + float FloatValue = 0.0f; + int32 IntValue = 0; + }; + + struct FControlRigContactMetrics + { + double MaxPositionErrorCm = 0.0; + double MaxRotationErrorDegrees = 0.0; + int32 WorstPositionFrame = 0; + int32 WorstRotationFrame = 0; + FTransform WorstPositionExpected = FTransform::Identity; + FTransform WorstPositionActual = FTransform::Identity; + }; + + struct FControlRigContactStabilizerQA + { + FName Control; + ERigControlType ControlType = ERigControlType::Transform; + TArray Expected; + FControlRigContactMetrics Metrics; + }; + + struct FControlRigPreparedContactQA + { + int32 OperationIndex = INDEX_NONE; + FName Control; + FName DrivenReference; + ERigControlType ControlType = ERigControlType::Transform; + bool bHasDrivenReference = false; + bool bUsedFkRotationChain = false; + bool bCheckRotation = false; + int32 FullWeightFrameCount = 0; + double PositionToleranceCm = 0.1; + double RotationToleranceDegrees = 0.5; + TArray Frames; + TArray ExpectedSubject; + FControlRigContactMetrics Metrics; + TArray Stabilizers; + }; + + class FControlRigSequenceFocusGuard + { + public: + explicit FControlRigSequenceFocusGuard(ULevelSequence* InSequence) + : Previous(ULevelSequenceEditorBlueprintLibrary::GetCurrentLevelSequence()) + , Target(InSequence) + { + if (Target && ULevelSequenceEditorBlueprintLibrary::GetFocusedLevelSequence() != Target) + { + bChanged = true; + bReady = ULevelSequenceEditorBlueprintLibrary::OpenLevelSequence(Target); + } + else + { + bReady = Target != nullptr; + } + if (bReady) + { + ULevelSequenceEditorBlueprintLibrary::RefreshCurrentLevelSequence(); + ULevelSequenceEditorBlueprintLibrary::ForceUpdate(); + bReady = ULevelSequenceEditorBlueprintLibrary::GetCurrentLevelSequence() == Target + && ULevelSequenceEditorBlueprintLibrary::GetFocusedLevelSequence() == Target; + } + } + + ~FControlRigSequenceFocusGuard() + { + if (!bChanged) + { + return; + } + if (Previous) + { + ULevelSequenceEditorBlueprintLibrary::OpenLevelSequence(Previous); + } + else + { + ULevelSequenceEditorBlueprintLibrary::CloseLevelSequence(); + } + } + + bool IsReady() const { return bReady; } + + private: + TObjectPtr Previous; + TObjectPtr Target; + bool bChanged = false; + bool bReady = false; + }; + + bool ControlRigSequencerSplitAssetPath( + const FString& InPath, + FString& OutPackagePath, + FString& OutName, + FString& OutError) + { + FString PackageName = InPath; + PackageName.TrimStartAndEndInline(); + int32 DotIndex = INDEX_NONE; + if (PackageName.FindLastChar(TEXT('.'), DotIndex)) + { + PackageName.LeftInline(DotIndex); + } + FText InvalidReason; + if (!FPackageName::IsValidLongPackageName(PackageName, true, &InvalidReason)) + { + OutError = FString::Printf(TEXT("Invalid asset path '%s': %s"), *InPath, *InvalidReason.ToString()); + return false; + } + if (MCPIsProtectedAssetPath(PackageName)) + { + OutError = FString::Printf(TEXT("Refusing to create or edit protected asset path '%s'"), *PackageName); + return false; + } + OutName = FPackageName::GetLongPackageAssetName(PackageName); + OutPackagePath = FPackageName::GetLongPackagePath(PackageName); + if (OutName.IsEmpty() || OutPackagePath.IsEmpty()) + { + OutError = FString::Printf(TEXT("Asset path must include a package and asset name: '%s'"), *InPath); + return false; + } + return true; + } + + bool ControlRigSequencerReadRate( + const TSharedPtr& Params, + const TCHAR* Field, + const FFrameRate& Default, + FFrameRate& OutRate, + FString& OutError) + { + OutRate = Default; + const TSharedPtr* Value = Params->Values.Find(Field); + if (!Value || !Value->IsValid() || (*Value)->IsNull()) + { + return true; + } + if ((*Value)->Type == EJson::Number) + { + const double Number = (*Value)->AsNumber(); + if (!FMath::IsFinite(Number) || Number <= 0.0 || Number > static_cast(MAX_int32)) + { + OutError = FString::Printf(TEXT("'%s' must be a positive frame rate"), Field); + return false; + } + OutRate = FFrameRate(FMath::RoundToInt(Number), 1); + return OutRate.IsValid(); + } + if ((*Value)->Type != EJson::Object) + { + OutError = FString::Printf(TEXT("'%s' must be a number or {numerator, denominator}"), Field); + return false; + } + const TSharedPtr Object = (*Value)->AsObject(); + double Numerator = 0.0; + double Denominator = 1.0; + if (!Object.IsValid() || !Object->TryGetNumberField(TEXT("numerator"), Numerator)) + { + OutError = FString::Printf(TEXT("'%s.numerator' is required"), Field); + return false; + } + Object->TryGetNumberField(TEXT("denominator"), Denominator); + if (!FMath::IsFinite(Numerator) || !FMath::IsFinite(Denominator) + || Numerator <= 0.0 || Denominator <= 0.0 + || Numerator > static_cast(MAX_int32) || Denominator > static_cast(MAX_int32)) + { + OutError = FString::Printf(TEXT("'%s' numerator and denominator must be positive integers"), Field); + return false; + } + OutRate = FFrameRate(FMath::RoundToInt(Numerator), FMath::RoundToInt(Denominator)); + if (!OutRate.IsValid()) + { + OutError = FString::Printf(TEXT("'%s' is not a valid frame rate"), Field); + return false; + } + return true; + } + + TSharedPtr ControlRigSequencerRateJson(const FFrameRate& Rate) + { + auto Object = MakeShared(); + Object->SetNumberField(TEXT("numerator"), Rate.Numerator); + Object->SetNumberField(TEXT("denominator"), Rate.Denominator); + Object->SetNumberField(TEXT("decimal"), Rate.AsDecimal()); + return Object; + } + + bool ControlRigSequencerReadVector( + const TSharedPtr& Parent, + const TCHAR* Field, + FVector& OutValue, + FString& OutError) + { + const TSharedPtr* Object = nullptr; + if (!Parent->TryGetObjectField(Field, Object) || !Object || !Object->IsValid()) + { + OutError = FString::Printf(TEXT("'%s' must be an object with x, y and z"), Field); + return false; + } + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + if (!(*Object)->TryGetNumberField(TEXT("x"), X) + || !(*Object)->TryGetNumberField(TEXT("y"), Y) + || !(*Object)->TryGetNumberField(TEXT("z"), Z) + || !FMath::IsFinite(X) || !FMath::IsFinite(Y) || !FMath::IsFinite(Z)) + { + OutError = FString::Printf(TEXT("'%s' must contain finite x, y and z numbers"), Field); + return false; + } + OutValue = FVector(X, Y, Z); + return true; + } + + bool ControlRigSequencerReadRotation( + const TSharedPtr& Parent, + FQuat& OutValue, + FString& OutError) + { + const TSharedPtr* Quaternion = nullptr; + if (Parent->TryGetObjectField(TEXT("rotation"), Quaternion) && Quaternion && Quaternion->IsValid()) + { + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + double W = 0.0; + if (!(*Quaternion)->TryGetNumberField(TEXT("x"), X) + || !(*Quaternion)->TryGetNumberField(TEXT("y"), Y) + || !(*Quaternion)->TryGetNumberField(TEXT("z"), Z) + || !(*Quaternion)->TryGetNumberField(TEXT("w"), W) + || !FMath::IsFinite(X) || !FMath::IsFinite(Y) + || !FMath::IsFinite(Z) || !FMath::IsFinite(W)) + { + OutError = TEXT("'rotation' must contain finite x, y, z and w numbers"); + return false; + } + OutValue = FQuat(X, Y, Z, W); + if (OutValue.SizeSquared() <= UE_SMALL_NUMBER) + { + OutError = TEXT("'rotation' quaternion must have non-zero length"); + return false; + } + OutValue.Normalize(); + return true; + } + + const TSharedPtr* Euler = nullptr; + if (!Parent->TryGetObjectField(TEXT("rotationDegrees"), Euler) || !Euler || !Euler->IsValid()) + { + OutError = TEXT("Expected 'rotation' quaternion or 'rotationDegrees'"); + return false; + } + double Pitch = 0.0; + double Yaw = 0.0; + double Roll = 0.0; + if (!(*Euler)->TryGetNumberField(TEXT("pitch"), Pitch) + || !(*Euler)->TryGetNumberField(TEXT("yaw"), Yaw) + || !(*Euler)->TryGetNumberField(TEXT("roll"), Roll) + || !FMath::IsFinite(Pitch) || !FMath::IsFinite(Yaw) || !FMath::IsFinite(Roll)) + { + OutError = TEXT("'rotationDegrees' must contain finite pitch, yaw and roll numbers"); + return false; + } + OutValue = FRotator(Pitch, Yaw, Roll).Quaternion(); + OutValue.Normalize(); + return true; + } + + bool ControlRigSequencerReadNormalizedQuaternion( + const TSharedPtr& Parent, + FQuat& OutValue, + FString& OutError) + { + const TSharedPtr* Quaternion = nullptr; + if (!Parent->TryGetObjectField(TEXT("rotationQuaternion"), Quaternion) + || !Quaternion || !Quaternion->IsValid()) + { + OutError = TEXT("'rotationQuaternion' must be an object with finite x, y, z and w numbers"); + return false; + } + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + double W = 0.0; + if (!(*Quaternion)->TryGetNumberField(TEXT("x"), X) + || !(*Quaternion)->TryGetNumberField(TEXT("y"), Y) + || !(*Quaternion)->TryGetNumberField(TEXT("z"), Z) + || !(*Quaternion)->TryGetNumberField(TEXT("w"), W) + || !FMath::IsFinite(X) || !FMath::IsFinite(Y) + || !FMath::IsFinite(Z) || !FMath::IsFinite(W)) + { + OutError = TEXT("'rotationQuaternion' must contain finite x, y, z and w numbers"); + return false; + } + OutValue = FQuat(X, Y, Z, W); + const double Length = OutValue.Size(); + if (Length <= UE_SMALL_NUMBER) + { + OutError = TEXT("'rotationQuaternion' must have non-zero length"); + return false; + } + constexpr double NormalizedTolerance = 1e-3; + if (FMath::Abs(Length - 1.0) > NormalizedTolerance) + { + OutError = FString::Printf( + TEXT("'rotationQuaternion' must be normalized within %.4f (length was %.8f)"), + NormalizedTolerance, Length); + return false; + } + OutValue.Normalize(); + return true; + } + + bool ControlRigSequencerReadTransformPatch( + const TSharedPtr& Object, + bool bRequireAny, + FControlRigTransformPatch& OutPatch, + FString& OutError) + { + if (!Object.IsValid()) + { + OutError = TEXT("Transform must be an object"); + return false; + } + if (Object->HasField(TEXT("translation"))) + { + if (!ControlRigSequencerReadVector(Object, TEXT("translation"), OutPatch.Translation, OutError)) return false; + OutPatch.bTranslation = true; + } + else if (Object->HasField(TEXT("translationCm"))) + { + if (!ControlRigSequencerReadVector(Object, TEXT("translationCm"), OutPatch.Translation, OutError)) return false; + OutPatch.bTranslation = true; + } + if (Object->HasField(TEXT("rotation")) || Object->HasField(TEXT("rotationDegrees"))) + { + if (!ControlRigSequencerReadRotation(Object, OutPatch.Rotation, OutError)) return false; + OutPatch.bRotation = true; + } + if (Object->HasField(TEXT("scale"))) + { + if (!ControlRigSequencerReadVector(Object, TEXT("scale"), OutPatch.Scale, OutError)) return false; + OutPatch.bScale = true; + } + else if (Object->HasField(TEXT("scaleMultiplier"))) + { + if (!ControlRigSequencerReadVector(Object, TEXT("scaleMultiplier"), OutPatch.Scale, OutError)) return false; + OutPatch.bScale = true; + } + if (bRequireAny && !OutPatch.bTranslation && !OutPatch.bRotation && !OutPatch.bScale) + { + OutError = TEXT("Transform must specify translation, rotation/rotationDegrees, or scale"); + return false; + } + return true; + } + + TSharedPtr ControlRigSequencerTransformJson(const FTransform& Transform) + { + auto Object = MakeShared(); + const FVector Translation = Transform.GetTranslation(); + const FQuat Rotation = Transform.GetRotation().GetNormalized(); + const FRotator Euler = Rotation.Rotator(); + const FVector Scale = Transform.GetScale3D(); + + auto TranslationObject = MakeShared(); + TranslationObject->SetNumberField(TEXT("x"), Translation.X); + TranslationObject->SetNumberField(TEXT("y"), Translation.Y); + TranslationObject->SetNumberField(TEXT("z"), Translation.Z); + Object->SetObjectField(TEXT("translation"), TranslationObject); + + auto RotationObject = MakeShared(); + RotationObject->SetNumberField(TEXT("x"), Rotation.X); + RotationObject->SetNumberField(TEXT("y"), Rotation.Y); + RotationObject->SetNumberField(TEXT("z"), Rotation.Z); + RotationObject->SetNumberField(TEXT("w"), Rotation.W); + Object->SetObjectField(TEXT("rotation"), RotationObject); + + auto EulerObject = MakeShared(); + EulerObject->SetNumberField(TEXT("pitch"), Euler.Pitch); + EulerObject->SetNumberField(TEXT("yaw"), Euler.Yaw); + EulerObject->SetNumberField(TEXT("roll"), Euler.Roll); + Object->SetObjectField(TEXT("rotationDegrees"), EulerObject); + + auto ScaleObject = MakeShared(); + ScaleObject->SetNumberField(TEXT("x"), Scale.X); + ScaleObject->SetNumberField(TEXT("y"), Scale.Y); + ScaleObject->SetNumberField(TEXT("z"), Scale.Z); + Object->SetObjectField(TEXT("scale"), ScaleObject); + return Object; + } + + bool ControlRigSequencerReadFrames( + const TSharedPtr& Object, + TArray& OutFrames, + FString& OutError) + { + double SingleFrame = 0.0; + if (Object->TryGetNumberField(TEXT("frame"), SingleFrame)) + { + if (!FMath::IsFinite(SingleFrame) + || !FMath::IsNearlyEqual(SingleFrame, FMath::RoundToDouble(SingleFrame)) + || SingleFrame < static_cast(MIN_int32) || SingleFrame > static_cast(MAX_int32)) + { + OutError = TEXT("'frame' must be an integer"); + return false; + } + OutFrames.Add(FFrameNumber(static_cast(FMath::RoundToInt(SingleFrame)))); + } + + const TArray>* Frames = nullptr; + if (Object->TryGetArrayField(TEXT("frames"), Frames) && Frames) + { + for (const TSharedPtr& FrameValue : *Frames) + { + if (!FrameValue.IsValid() || FrameValue->Type != EJson::Number) + { + OutError = TEXT("Every item in 'frames' must be an integer"); + return false; + } + const double Number = FrameValue->AsNumber(); + if (!FMath::IsFinite(Number) + || !FMath::IsNearlyEqual(Number, FMath::RoundToDouble(Number)) + || Number < static_cast(MIN_int32) || Number > static_cast(MAX_int32)) + { + OutError = TEXT("Every item in 'frames' must be an integer"); + return false; + } + OutFrames.AddUnique(FFrameNumber(static_cast(FMath::RoundToInt(Number)))); + } + } + if (OutFrames.IsEmpty()) + { + OutError = TEXT("Specify 'frame' or a non-empty 'frames' array"); + return false; + } + OutFrames.Sort([](FFrameNumber A, FFrameNumber B) { return A.Value < B.Value; }); + return true; + } + + bool ControlRigSequencerReadSpace( + const FString& InSpace, + EControlRigTransformSpace& OutSpace, + FString& OutCanonical, + FString& OutError) + { + if (InSpace.IsEmpty() || InSpace.Equals(TEXT("local"), ESearchCase::IgnoreCase)) + { + OutSpace = EControlRigTransformSpace::Local; + OutCanonical = TEXT("local"); + return true; + } + if (InSpace.Equals(TEXT("global"), ESearchCase::IgnoreCase) + || InSpace.Equals(TEXT("component"), ESearchCase::IgnoreCase)) + { + OutSpace = EControlRigTransformSpace::Global; + OutCanonical = TEXT("component"); + return true; + } + OutError = TEXT("'space' must be 'local', 'component', or 'global'"); + return false; + } + + void ControlRigSequencerDisplayRange(UMovieScene* MovieScene, int32& OutStart, int32& OutEndExclusive) + { + const TRange Range = MovieScene->GetPlaybackRange(); + const FFrameRate TickRate = MovieScene->GetTickResolution(); + const FFrameRate DisplayRate = MovieScene->GetDisplayRate(); + OutStart = FFrameRate::TransformTime(FFrameTime(Range.GetLowerBoundValue()), TickRate, DisplayRate).RoundToFrame().Value; + OutEndExclusive = FFrameRate::TransformTime(FFrameTime(Range.GetUpperBoundValue()), TickRate, DisplayRate).RoundToFrame().Value; + } + + bool ControlRigSequencerResolveSession( + const TSharedPtr& Params, + FControlRigSequenceSession& OutSession, + FString& OutError) + { + if (!Params->TryGetStringField(TEXT("sequencePath"), OutSession.SequencePath) || OutSession.SequencePath.IsEmpty()) + { + OutError = TEXT("Missing 'sequencePath' parameter"); + return false; + } + if (!Params->TryGetStringField(TEXT("bindingTag"), OutSession.BindingTag) || OutSession.BindingTag.IsEmpty()) + { + OutError = TEXT("Missing 'bindingTag' parameter"); + return false; + } + + OutSession.Sequence = Cast(UEditorAssetLibrary::LoadAsset(OutSession.SequencePath)); + if (!OutSession.Sequence) + { + OutError = FString::Printf(TEXT("LevelSequence not found: %s"), *OutSession.SequencePath); + return false; + } + OutSession.MovieScene = OutSession.Sequence->GetMovieScene(); + if (!OutSession.MovieScene) + { + OutError = TEXT("LevelSequence has no MovieScene"); + return false; + } + + const FMovieSceneObjectBindingIDs* Tagged = OutSession.MovieScene->AllTaggedBindings().Find(FName(*OutSession.BindingTag)); + if (!Tagged || Tagged->IDs.IsEmpty()) + { + OutError = FString::Printf(TEXT("Binding tag '%s' was not found"), *OutSession.BindingTag); + return false; + } + for (const FMovieSceneObjectBindingID& ID : Tagged->IDs) + { + if (OutSession.MovieScene->FindBinding(ID.GetGuid())) + { + if (OutSession.BindingGuid.IsValid() && OutSession.BindingGuid != ID.GetGuid()) + { + OutError = FString::Printf(TEXT("Binding tag '%s' resolves to more than one binding"), *OutSession.BindingTag); + return false; + } + OutSession.BindingGuid = ID.GetGuid(); + } + } + if (!OutSession.BindingGuid.IsValid()) + { + OutError = FString::Printf(TEXT("Binding tag '%s' has no binding in this sequence"), *OutSession.BindingTag); + return false; + } + + const TArray Tracks = OutSession.MovieScene->FindTracks( + UMovieSceneControlRigParameterTrack::StaticClass(), OutSession.BindingGuid, NAME_None); + for (UMovieSceneTrack* Candidate : Tracks) + { + auto* RigTrack = Cast(Candidate); + if (!RigTrack || !RigTrack->GetControlRig()) + { + continue; + } + if (OutSession.Track) + { + OutError = FString::Printf(TEXT("Binding '%s' has more than one Control Rig track"), *OutSession.BindingTag); + return false; + } + OutSession.Track = RigTrack; + OutSession.ControlRig = RigTrack->GetControlRig(); + } + if (!OutSession.Track || !OutSession.ControlRig) + { + OutError = FString::Printf(TEXT("Binding '%s' has no Control Rig track"), *OutSession.BindingTag); + return false; + } + + OutSession.Section = Cast(OutSession.Track->GetSectionToKey()); + if (!OutSession.Section) + { + for (UMovieSceneSection* Candidate : OutSession.Track->GetAllSections()) + { + OutSession.Section = Cast(Candidate); + if (OutSession.Section) break; + } + } + if (!OutSession.Section) + { + OutError = TEXT("Control Rig track has no parameter section"); + return false; + } + return true; + } + + bool ControlRigSequencerIsTransformControl(const FRigControlElement* Control) + { + if (!Control) return false; + switch (Control->Settings.ControlType) + { + case ERigControlType::Position: + case ERigControlType::Scale: + case ERigControlType::Rotator: + case ERigControlType::Transform: + case ERigControlType::TransformNoScale: + case ERigControlType::EulerTransform: + return true; + default: + return false; + } + } + + bool ControlRigSequencerIsFloatControl(const FRigControlElement* Control) + { + return Control && (Control->Settings.ControlType == ERigControlType::Float + || Control->Settings.ControlType == ERigControlType::ScaleFloat); + } + + bool ControlRigSequencerIsEnumOption(const UEnum* Enum, int32 Index) + { + return Enum && Index >= 0 && Index < Enum->NumEnums() + && !Enum->HasMetaData(TEXT("Hidden"), Index) + && !(Enum->ContainsExistingMax() && Index == Enum->NumEnums() - 1); + } + + int32 ControlRigSequencerFindEnumOption(const UEnum* Enum, int32 Value) + { + if (!Enum) return INDEX_NONE; + for (int32 Index = 0; Index < Enum->NumEnums(); ++Index) + { + if (ControlRigSequencerIsEnumOption(Enum, Index) && Enum->GetValueByIndex(Index) == Value) + { + return Index; + } + } + return INDEX_NONE; + } + + bool ControlRigSequencerIsValidEnumValue(const UEnum* Enum, int32 Value) + { + return ControlRigSequencerFindEnumOption(Enum, Value) != INDEX_NONE; + } + + TArray> ControlRigSequencerControlsJson( + UControlRig* ControlRig, + const TArray* ControlFilter = nullptr) + { + TArray> Result; + if (!ControlRig || !ControlRig->GetHierarchy()) return Result; + + TArray Controls = ControlRig->GetHierarchy()->GetControls(); + Controls.Sort([](const FRigControlElement& A, const FRigControlElement& B) + { + return A.GetFName().LexicalLess(B.GetFName()); + }); + for (const FRigControlElement* Control : Controls) + { + if (!Control) continue; + if (ControlFilter && !ControlFilter->Contains(Control->GetFName())) continue; + auto Object = MakeShared(); + Object->SetStringField(TEXT("name"), Control->GetFName().ToString()); + const FString ControlType = StaticEnum()->GetNameStringByValue( + static_cast(Control->Settings.ControlType)); + Object->SetStringField(TEXT("type"), ControlType); + Object->SetStringField(TEXT("controlType"), ControlType); + Object->SetBoolField(TEXT("transformControl"), ControlRigSequencerIsTransformControl(Control)); + Object->SetBoolField(TEXT("animatable"), Control->Settings.IsAnimatable()); + if (const UEnum* ControlEnum = Control->Settings.ControlEnum.Get()) + { + Object->SetStringField(TEXT("enumName"), ControlEnum->GetName()); + Object->SetStringField(TEXT("enumPath"), ControlEnum->GetPathName()); + TArray> Options; + for (int32 Index = 0; Index < ControlEnum->NumEnums(); ++Index) + { + if (!ControlRigSequencerIsEnumOption(ControlEnum, Index)) continue; + auto Option = MakeShared(); + Option->SetStringField(TEXT("name"), ControlEnum->GetNameStringByIndex(Index)); + Option->SetStringField(TEXT("displayName"), ControlEnum->GetDisplayNameTextByIndex(Index).ToString()); + Option->SetNumberField(TEXT("value"), ControlEnum->GetValueByIndex(Index)); + Options.Add(MakeShared(Option)); + } + Object->SetArrayField(TEXT("enumOptions"), Options); + } + Result.Add(MakeShared(Object)); + } + return Result; + } + + TSharedPtr ControlRigSequencerSessionJson(const FControlRigSequenceSession& Session) + { + auto Result = MCPSuccess(); + Result->SetStringField(TEXT("sequencePath"), Session.Sequence->GetPathName()); + Result->SetStringField(TEXT("bindingTag"), Session.BindingTag); + Result->SetStringField(TEXT("bindingGuid"), Session.BindingGuid.ToString()); + Result->SetStringField(TEXT("trackName"), Session.Track->GetTrackName().ToString()); + Result->SetStringField(TEXT("trackPath"), Session.Track->GetPathName()); + Result->SetStringField(TEXT("sectionPath"), Session.Section->GetPathName()); + Result->SetStringField(TEXT("controlRigClass"), Session.ControlRig->GetClass()->GetPathName()); + Result->SetBoolField(TEXT("layered"), Session.ControlRig->IsAdditive()); + Result->SetObjectField(TEXT("displayRate"), ControlRigSequencerRateJson(Session.MovieScene->GetDisplayRate())); + Result->SetObjectField(TEXT("tickResolution"), ControlRigSequencerRateJson(Session.MovieScene->GetTickResolution())); + int32 StartFrame = 0; + int32 EndFrameExclusive = 0; + ControlRigSequencerDisplayRange(Session.MovieScene, StartFrame, EndFrameExclusive); + Result->SetNumberField(TEXT("startFrame"), StartFrame); + Result->SetNumberField(TEXT("endFrameExclusive"), EndFrameExclusive); + TArray> Controls = ControlRigSequencerControlsJson(Session.ControlRig); + Result->SetNumberField(TEXT("controlCount"), Controls.Num()); + Result->SetArrayField(TEXT("controls"), Controls); + return Result; + } + + FTransform ControlRigSequencerApplySetPatch(const FTransform& Base, const FControlRigTransformPatch& Patch) + { + FTransform Result = Base; + if (Patch.bTranslation) Result.SetTranslation(Patch.Translation); + if (Patch.bRotation) Result.SetRotation(Patch.Rotation.GetNormalized()); + if (Patch.bScale) Result.SetScale3D(Patch.Scale); + return Result; + } + + FTransform ControlRigSequencerApplyOffset( + const FTransform& Base, + const FControlRigTransformPatch& Patch, + double Weight, + EControlRigTransformSpace Space) + { + FTransform Result = Base; + if (Patch.bTranslation) + { + Result.AddToTranslation(Patch.Translation * Weight); + } + if (Patch.bRotation) + { + const FQuat Delta = FQuat::Slerp(FQuat::Identity, Patch.Rotation, Weight).GetNormalized(); + const FQuat Rotation = Space == EControlRigTransformSpace::Local + ? Result.GetRotation() * Delta + : Delta * Result.GetRotation(); + Result.SetRotation(Rotation.GetNormalized()); + } + if (Patch.bScale) + { + const FVector Multiplier = FMath::Lerp(FVector::OneVector, Patch.Scale, Weight); + Result.SetScale3D(Result.GetScale3D() * Multiplier); + } + return Result; + } + + bool ControlRigSequencerTransformMatches( + const FTransform& Expected, + const FTransform& Actual, + ERigControlType ControlType) + { + const bool bTranslationMatches = Expected.GetTranslation().Equals(Actual.GetTranslation(), 0.01); + const bool bScaleMatches = Expected.GetScale3D().Equals(Actual.GetScale3D(), 0.001); + const double RotationDot = FMath::Abs( + Expected.GetRotation().GetNormalized() | Actual.GetRotation().GetNormalized()); + const bool bRotationMatches = RotationDot >= FMath::Cos(FMath::DegreesToRadians(0.1) * 0.5); + switch (ControlType) + { + case ERigControlType::Position: return bTranslationMatches; + case ERigControlType::Scale: return bScaleMatches; + case ERigControlType::Rotator: return bRotationMatches; + case ERigControlType::TransformNoScale: return bTranslationMatches && bRotationMatches; + default: return bTranslationMatches && bRotationMatches && bScaleMatches; + } + } + + bool ControlRigSequencerPatchAffectsControl( + const FControlRigTransformPatch& Patch, + ERigControlType ControlType) + { + switch (ControlType) + { + case ERigControlType::Position: return Patch.bTranslation; + case ERigControlType::Scale: return Patch.bScale; + case ERigControlType::Rotator: return Patch.bRotation; + case ERigControlType::TransformNoScale: return Patch.bTranslation || Patch.bRotation; + default: return Patch.bTranslation || Patch.bRotation || Patch.bScale; + } + } + + bool ControlRigSequencerControlHasTranslation(ERigControlType ControlType) + { + switch (ControlType) + { + case ERigControlType::Position: + case ERigControlType::Transform: + case ERigControlType::TransformNoScale: + case ERigControlType::EulerTransform: + return true; + default: + return false; + } + } + + bool ControlRigSequencerControlHasRotation(ERigControlType ControlType) + { + switch (ControlType) + { + case ERigControlType::Rotator: + case ERigControlType::Transform: + case ERigControlType::TransformNoScale: + case ERigControlType::EulerTransform: + return true; + default: + return false; + } + } + + double ControlRigSequencerSmoothStep(double Value) + { + const double T = FMath::Clamp(Value, 0.0, 1.0); + return T * T * (3.0 - 2.0 * T); + } + + double ControlRigSequencerContactWeight(int32 Index, int32 FrameCount, int32 BlendIn, int32 BlendOut) + { + double Weight = 1.0; + if (BlendIn > 0) + { + Weight = FMath::Min(Weight, ControlRigSequencerSmoothStep( + static_cast(Index) / static_cast(BlendIn))); + } + if (BlendOut > 0) + { + Weight = FMath::Min(Weight, ControlRigSequencerSmoothStep( + static_cast(FrameCount - 1 - Index) / static_cast(BlendOut))); + } + return Weight; + } + + FTransform ControlRigSequencerBlendContactTransform( + const FTransform& Before, + const FTransform& Target, + double Weight, + bool bBlendRotation) + { + FTransform Result = Before; + Result.SetTranslation(FMath::Lerp(Before.GetTranslation(), Target.GetTranslation(), Weight)); + if (bBlendRotation) + { + const FQuat From = Before.GetRotation().GetNormalized(); + FQuat To = Target.GetRotation().GetNormalized(); + if ((From | To) < 0.0) + { + To = FQuat(-To.X, -To.Y, -To.Z, -To.W); + } + Result.SetRotation(FQuat::Slerp(From, To, Weight).GetNormalized()); + } + Result.SetScale3D(Before.GetScale3D()); + return Result; + } + + bool ControlRigSequencerSolveRotationChain( + const TArray& SourceGlobals, + const FTransform& TargetEnd, + bool bTargetRotation, + TArray& OutGlobals, + double& OutPositionErrorCm) + { + if (SourceGlobals.Num() < 2) + { + return false; + } + + TArray Links; + Links.Reserve(SourceGlobals.Num()); + double MaximumReach = 0.0; + for (int32 Index = 0; Index < SourceGlobals.Num(); ++Index) + { + const FVector Position = SourceGlobals[Index].GetTranslation(); + const double Length = Index == 0 + ? 0.0 + : FVector::Distance(Position, SourceGlobals[Index - 1].GetTranslation()); + MaximumReach += Length; + Links.Emplace(Position, Length, Index, Index); + } + + AnimationCore::SolveFabrik( + Links, TargetEnd.GetTranslation(), MaximumReach, 0.001, 32); + + OutGlobals = SourceGlobals; + for (int32 Index = 0; Index < Links.Num(); ++Index) + { + OutGlobals[Index].SetTranslation(Links[Index].Position); + if (Index + 1 < Links.Num()) + { + const FVector SourceDirection = + SourceGlobals[Index + 1].GetTranslation() - SourceGlobals[Index].GetTranslation(); + const FVector SolvedDirection = Links[Index + 1].Position - Links[Index].Position; + if (!SourceDirection.IsNearlyZero() && !SolvedDirection.IsNearlyZero()) + { + const FQuat Delta = FQuat::FindBetweenNormals( + SourceDirection.GetSafeNormal(), SolvedDirection.GetSafeNormal()); + OutGlobals[Index].SetRotation( + (Delta * SourceGlobals[Index].GetRotation()).GetNormalized()); + } + } + else if (bTargetRotation) + { + OutGlobals[Index].SetRotation(TargetEnd.GetRotation().GetNormalized()); + } + } + + OutPositionErrorCm = FVector::Distance( + OutGlobals.Last().GetTranslation(), TargetEnd.GetTranslation()); + return !OutGlobals.ContainsByPredicate([](const FTransform& Transform) + { + return Transform.ContainsNaN(); + }); + } + + bool ControlRigSequencerRegisterWriteFrames( + TSet& WrittenKeys, + FName Control, + const TArray& Frames, + int32 OperationIndex, + FString& OutError) + { + for (const FFrameNumber Frame : Frames) + { + const FString Key = FString::Printf( + TEXT("%s|%d"), *Control.ToString().ToLower(), Frame.Value); + if (WrittenKeys.Contains(Key)) + { + OutError = FString::Printf( + TEXT("operations[%d] overlaps another edit at %s frame %d"), + OperationIndex, *Control.ToString(), Frame.Value); + return false; + } + WrittenKeys.Add(Key); + } + return true; + } + + USkeletalMeshComponent* ControlRigSequencerBoundSkeletalMesh(UControlRig* ControlRig) + { + if (!ControlRig) return nullptr; + const TSharedPtr ObjectBinding = ControlRig->GetObjectBinding(); + return ObjectBinding.IsValid() + ? Cast(ObjectBinding->GetBoundObject()) + : nullptr; + } + + bool ControlRigSequencerSampleReferenceTransforms( + const FControlRigSequenceSession& Session, + FName Reference, + const TArray& Frames, + TArray& OutTransforms, + FString& OutError) + { + USkeletalMeshComponent* Component = ControlRigSequencerBoundSkeletalMesh(Session.ControlRig); + if (!Component) + { + OutError = TEXT("The Control Rig is not bound to a skeletal mesh component"); + return false; + } + if (!Component->DoesSocketExist(Reference)) + { + OutError = FString::Printf(TEXT("Driven bone or socket was not found: %s"), *Reference.ToString()); + return false; + } + UMovieSceneSkeletalAnimationSection* SourceSection = nullptr; + for (UMovieSceneTrack* Track : Session.MovieScene->FindTracks( + UMovieSceneSkeletalAnimationTrack::StaticClass(), Session.BindingGuid, NAME_None)) + { + for (UMovieSceneSection* Section : Track->GetAllSections()) + { + auto* Candidate = Cast(Section); + if (!Candidate || !Candidate->Params.Animation) continue; + if (SourceSection && SourceSection != Candidate) + { + OutError = TEXT("contact_lock requires one source animation section in its edit session"); + return false; + } + SourceSection = Candidate; + } + } + if (!SourceSection || !SourceSection->Params.Animation) + { + OutError = TEXT("The Control Rig edit session has no source animation section"); + return false; + } + + FAnimPoseEvaluationOptions Options; + Options.EvaluationType = EAnimDataEvalType::Raw; + Options.OptionalSkeletalMesh = Component->GetSkeletalMeshAsset(); + Options.bShouldRetarget = true; + const bool bSocket = Component->GetSocketByName(Reference) != nullptr; + OutTransforms.Init(FTransform::Identity, Frames.Num()); + for (int32 Index = 0; Index < Frames.Num(); ++Index) + { + const FFrameTime TickTime = FFrameRate::TransformTime( + FFrameTime(Frames[Index]), + Session.MovieScene->GetDisplayRate(), + Session.MovieScene->GetTickResolution()); + const double AnimationTime = SourceSection->MapTimeToAnimation( + TickTime, Session.MovieScene->GetTickResolution()); + FAnimPose Pose; + UAnimPoseExtensions::GetAnimPoseAtTime( + SourceSection->Params.Animation, AnimationTime, Options, Pose); + OutTransforms[Index] = bSocket + ? UAnimPoseExtensions::GetSocketPose(Pose, Reference, EAnimPoseSpaces::World) + : UAnimPoseExtensions::GetBonePose(Pose, Reference, EAnimPoseSpaces::World); + if (OutTransforms[Index].ContainsNaN()) + { + OutError = FString::Printf( + TEXT("Driven reference %s evaluated to an invalid component-space transform"), + *Reference.ToString()); + return false; + } + } + if (OutTransforms.IsEmpty()) + { + OutError = TEXT("contact_lock requires at least one frame"); + return false; + } + return true; + } + + double ControlRigSequencerRotationErrorDegrees(const FQuat& Expected, const FQuat& Actual) + { + const double Dot = FMath::Clamp( + FMath::Abs(Expected.GetNormalized() | Actual.GetNormalized()), 0.0, 1.0); + return FMath::RadiansToDegrees(2.0 * FMath::Acos(Dot)); + } + + void ControlRigSequencerMeasureContact( + const TArray& Frames, + const TArray& Expected, + const TArray& Actual, + bool bCheckPosition, + bool bCheckRotation, + FControlRigContactMetrics& OutMetrics) + { + for (int32 Index = 0; Index < Frames.Num(); ++Index) + { + if (bCheckPosition) + { + const double Error = FVector::Distance( + Expected[Index].GetTranslation(), Actual[Index].GetTranslation()); + if (Index == 0 || Error > OutMetrics.MaxPositionErrorCm) + { + OutMetrics.MaxPositionErrorCm = Error; + OutMetrics.WorstPositionFrame = Frames[Index].Value; + OutMetrics.WorstPositionExpected = Expected[Index]; + OutMetrics.WorstPositionActual = Actual[Index]; + } + } + if (bCheckRotation) + { + const double Error = ControlRigSequencerRotationErrorDegrees( + Expected[Index].GetRotation(), Actual[Index].GetRotation()); + if (Index == 0 || Error > OutMetrics.MaxRotationErrorDegrees) + { + OutMetrics.MaxRotationErrorDegrees = Error; + OutMetrics.WorstRotationFrame = Frames[Index].Value; + } + } + } + } + + TSharedPtr ControlRigSequencerContactMetricsJson( + const FControlRigContactMetrics& Metrics, + bool bCheckPosition, + bool bCheckRotation) + { + auto Object = MakeShared(); + if (bCheckPosition) + { + Object->SetNumberField(TEXT("maxPositionErrorCm"), Metrics.MaxPositionErrorCm); + Object->SetNumberField(TEXT("worstPositionFrame"), Metrics.WorstPositionFrame); + Object->SetObjectField( + TEXT("worstPositionExpected"), ControlRigSequencerTransformJson(Metrics.WorstPositionExpected)); + Object->SetObjectField( + TEXT("worstPositionActual"), ControlRigSequencerTransformJson(Metrics.WorstPositionActual)); + } + if (bCheckRotation) + { + Object->SetNumberField(TEXT("maxRotationErrorDegrees"), Metrics.MaxRotationErrorDegrees); + Object->SetNumberField(TEXT("worstRotationFrame"), Metrics.WorstRotationFrame); + } + return Object; + } +} + +#if WITH_DEV_AUTOMATION_TESTS +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FUEMCPControlRigContactRotationChainTest, + "UE_MCP.Animation.ControlRig.ContactRotationChain", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FUEMCPControlRigContactRotationChainTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + const TArray SourceGlobals{ + FTransform(FQuat::Identity, FVector(0.0, 0.0, 0.0)), + FTransform(FQuat::Identity, FVector(10.0, 0.0, 0.0)), + FTransform(FQuat::Identity, FVector(20.0, 0.0, 0.0)), + }; + const FTransform TargetEnd(FQuat::Identity, FVector(10.0, 10.0, 0.0)); + TArray SolvedGlobals; + double PositionErrorCm = 0.0; + TestTrue( + TEXT("Rotation chain solves a reachable contact"), + ControlRigSequencerSolveRotationChain( + SourceGlobals, TargetEnd, false, SolvedGlobals, PositionErrorCm)); + TestTrue(TEXT("Solved contact is within one millimetre"), PositionErrorCm <= 0.1); + TestTrue( + TEXT("Driver translation remains unchanged"), + SolvedGlobals[0].GetTranslation().Equals(SourceGlobals[0].GetTranslation(), UE_KINDA_SMALL_NUMBER)); + TestTrue( + TEXT("First local bone translation remains unchanged"), + SolvedGlobals[1].GetRelativeTransform(SolvedGlobals[0]).GetTranslation().Equals( + SourceGlobals[1].GetRelativeTransform(SourceGlobals[0]).GetTranslation(), 0.001)); + TestTrue( + TEXT("Second local bone translation remains unchanged"), + SolvedGlobals[2].GetRelativeTransform(SolvedGlobals[1]).GetTranslation().Equals( + SourceGlobals[2].GetRelativeTransform(SourceGlobals[1]).GetTranslation(), 0.001)); + return true; +} +#endif + +#endif // UE_MCP_HAS_5_8_API + +TSharedPtr FAnimationHandlers::BeginControlRigEdit(const TSharedPtr& Params) +{ +#if !UE_MCP_HAS_5_8_API + return ControlRigSequencerUnsupported(); +#else + FString SequencePath; + FString SkeletalMeshPath; + FString SourceAnimationPath; + if (auto Error = RequireString(Params, TEXT("sequencePath"), SequencePath)) return Error; + if (auto Error = RequireString(Params, TEXT("skeletalMeshPath"), SkeletalMeshPath)) return Error; + if (auto Error = RequireString(Params, TEXT("sourceAnimationPath"), SourceAnimationPath)) return Error; + + FString PackagePath; + FString AssetName; + FString Error; + if (!ControlRigSequencerSplitAssetPath(SequencePath, PackagePath, AssetName, Error)) return MCPError(Error); + + USkeletalMesh* SkeletalMesh = Cast(UEditorAssetLibrary::LoadAsset(SkeletalMeshPath)); + if (!SkeletalMesh) return MCPError(FString::Printf(TEXT("SkeletalMesh not found: %s"), *SkeletalMeshPath)); + UAnimSequence* SourceAnimation = Cast(UEditorAssetLibrary::LoadAsset(SourceAnimationPath)); + if (!SourceAnimation) return MCPError(FString::Printf(TEXT("AnimSequence not found: %s"), *SourceAnimationPath)); + if (!SkeletalMesh->GetSkeleton() || !SourceAnimation->GetSkeleton() + || !SkeletalMesh->GetSkeleton()->IsCompatibleForEditor(SourceAnimation->GetSkeleton())) + { + return MCPError(TEXT("Source animation is not compatible with the skeletal mesh. Retarget it before beginning a Control Rig edit.")); + } + if (SourceAnimation->GetAdditiveAnimType() != AAT_None) + { + return MCPError(TEXT("Additive source animations require an explicit base pose. Flatten the additive with its intended base before beginning a Control Rig edit.")); + } + const double SourceRateScale = static_cast(SourceAnimation->RateScale); + if (!FMath::IsFinite(SourceRateScale) || SourceRateScale == 0.0) + { + return MCPError(TEXT("Source animation RateScale must be finite and non-zero before beginning a Control Rig edit")); + } + const float RawTimelinePlayRate = static_cast(1.0 / SourceRateScale); + if (!FMath::IsFinite(RawTimelinePlayRate)) + { + return MCPError(TEXT("Source animation RateScale cannot be converted to a finite Sequencer play rate")); + } + + const FString RigMode = OptionalString(Params, TEXT("rigMode"), TEXT("fk")).ToLower(); + UClass* ControlRigClass = nullptr; + if (RigMode == TEXT("fk")) + { + ControlRigClass = UFKControlRig::StaticClass(); + } + else if (RigMode == TEXT("asset")) + { + FString ControlRigPath; + if (auto RigError = RequireString(Params, TEXT("controlRigPath"), ControlRigPath)) return RigError; + UBlueprint* Blueprint = Cast(UEditorAssetLibrary::LoadAsset(ControlRigPath)); + if (!Blueprint || !Blueprint->GeneratedClass || !Blueprint->GeneratedClass->IsChildOf(UControlRig::StaticClass())) + { + return MCPError(FString::Printf(TEXT("Control Rig asset is invalid or has no generated Control Rig class: %s"), *ControlRigPath)); + } + ControlRigClass = Blueprint->GeneratedClass; + UControlRig* DefaultRig = Cast(ControlRigClass->GetDefaultObject()); + const FName LegacyInverse(TEXT("Inverse")); + if (!DefaultRig || !(DefaultRig->SupportsEvent(FRigUnit_InverseExecution::EventName) || DefaultRig->SupportsEvent(LegacyInverse))) + { + return MCPError(TEXT("Control Rig must support inverse execution before an AnimSequence can be baked into it")); + } + } + else + { + return MCPError(TEXT("'rigMode' must be 'fk' or 'asset'")); + } + + FFrameRate DisplayRate; + if (!ControlRigSequencerReadRate(Params, TEXT("displayRate"), SourceAnimation->GetSamplingFrameRate(), DisplayRate, Error)) + { + return MCPError(Error); + } + double StartNumber = 0.0; + Params->TryGetNumberField(TEXT("startFrame"), StartNumber); + if (!FMath::IsFinite(StartNumber) + || !FMath::IsNearlyEqual(StartNumber, FMath::RoundToDouble(StartNumber)) + || StartNumber < static_cast(MIN_int32) || StartNumber > static_cast(MAX_int32)) + { + return MCPError(TEXT("'startFrame' must be an integer")); + } + const int32 StartFrame = static_cast(FMath::RoundToInt(StartNumber)); + const int32 DefaultDuration = FMath::Max(1, static_cast(FMath::RoundToInt(SourceAnimation->GetPlayLength() * DisplayRate.AsDecimal()))); + double EndNumber = static_cast(StartFrame) + static_cast(DefaultDuration); + Params->TryGetNumberField(TEXT("endFrame"), EndNumber); + if (!FMath::IsFinite(EndNumber) + || !FMath::IsNearlyEqual(EndNumber, FMath::RoundToDouble(EndNumber)) + || EndNumber < static_cast(MIN_int32) || EndNumber > static_cast(MAX_int32)) + { + return MCPError(TEXT("'endFrame' must be an integer")); + } + const int32 EndFrameExclusive = static_cast(FMath::RoundToInt(EndNumber)); + if (EndFrameExclusive <= StartFrame) return MCPError(TEXT("'endFrame' must be greater than 'startFrame'")); + if (EndFrameExclusive == MAX_int32) return MCPError(TEXT("'endFrame' is too large")); + if (static_cast(EndFrameExclusive) - static_cast(StartFrame) > ControlRigSequencerMaxFrames) + { + return MCPError(FString::Printf(TEXT("Control Rig edit sessions are limited to %d frames"), ControlRigSequencerMaxFrames)); + } + + const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("error")).ToLower(); + if (OnConflict != TEXT("skip") && OnConflict != TEXT("error")) + { + return MCPError(TEXT("'onConflict' must be 'skip' or 'error'")); + } + const FString BindingTag = OptionalString(Params, TEXT("bindingTag"), FString::Printf(TEXT("Codex_%s"), *SkeletalMesh->GetName())); + if (BindingTag.IsEmpty()) return MCPError(TEXT("'bindingTag' must not be empty")); + const bool bLayered = OptionalBool(Params, TEXT("layered"), false); + + ULevelSequence* Sequence = Cast(UEditorAssetLibrary::LoadAsset(SequencePath)); + const bool bCreated = Sequence == nullptr; + if (Sequence && OnConflict == TEXT("error")) + { + return MCPError(FString::Printf(TEXT("LevelSequence already exists: %s"), *SequencePath)); + } + if (Sequence && OnConflict == TEXT("skip")) + { + FControlRigSequenceFocusGuard Focus(Sequence); + if (!Focus.IsReady()) return MCPError(TEXT("Could not focus the existing LevelSequence in Sequencer")); + FControlRigSequenceSession Session; + auto ResolveParams = MakeShared(); + ResolveParams->SetStringField(TEXT("sequencePath"), SequencePath); + ResolveParams->SetStringField(TEXT("bindingTag"), BindingTag); + if (!ControlRigSequencerResolveSession(ResolveParams, Session, Error)) return MCPError(Error); + auto Result = ControlRigSequencerSessionJson(Session); + MCPSetExisted(Result); + return MCPResult(Result); + } + if (!Sequence) + { + auto Created = MCPCreateAssetIdempotentNewObject(AssetName, PackagePath, TEXT("error"), TEXT("LevelSequence")); + if (Created.EarlyReturn) return Created.EarlyReturn; + Sequence = Created.Asset; + Sequence->Initialize(); + } + + UMovieScene* MovieScene = Sequence->GetMovieScene(); + if (!MovieScene) + { + if (bCreated) UEditorAssetLibrary::DeleteAsset(PackagePath + TEXT("/") + AssetName); + return MCPError(TEXT("LevelSequence has no MovieScene")); + } + + bool bBakeSucceeded = false; + FString BakeError; + FGuid BindingGuid; + { + const FScopedTransaction Transaction(NSLOCTEXT("UE_MCP", "BeginControlRigEdit", "Begin Control Rig Edit")); + Sequence->Modify(); + MovieScene->Modify(); + MovieScene->SetDisplayRate(DisplayRate); + MovieScene->SetTickResolutionDirectly(DisplayRate); + MovieScene->SetPlaybackRange(TRange(FFrameNumber(StartFrame), FFrameNumber(EndFrameExclusive))); + + ASkeletalMeshActor* ActorTemplate = DuplicateObject( + GetMutableDefault(), Sequence, + MakeUniqueObjectName(Sequence, ASkeletalMeshActor::StaticClass(), TEXT("CodexSkeletalMesh"))); + if (!ActorTemplate || !ActorTemplate->GetSkeletalMeshComponent()) + { + BakeError = TEXT("Failed to create the LevelSequence skeletal mesh spawnable template"); + } + else + { + ActorTemplate->SetFlags(RF_Transactional); + ActorTemplate->GetSkeletalMeshComponent()->SetSkeletalMeshAsset(SkeletalMesh); + BindingGuid = MovieScene->AddSpawnable(ActorTemplate->GetName(), *ActorTemplate); + auto* SpawnTrack = BindingGuid.IsValid() + ? MovieScene->AddTrack(BindingGuid) + : nullptr; + UMovieSceneSection* SpawnSection = SpawnTrack ? SpawnTrack->CreateNewSection() : nullptr; + if (!BindingGuid.IsValid() || !SpawnTrack || !SpawnSection) + { + BakeError = TEXT("Failed to create the LevelSequence skeletal mesh spawnable binding"); + } + else + { + SpawnTrack->AddSection(*SpawnSection); + MovieScene->TagBinding(FName(*BindingTag), UE::MovieScene::FFixedObjectBindingID(BindingGuid, MovieSceneSequenceID::Root)); + + auto* AnimationTrack = Cast( + MovieScene->AddTrack(UMovieSceneSkeletalAnimationTrack::StaticClass(), BindingGuid)); + auto* AnimationSection = AnimationTrack + ? Cast(AnimationTrack->CreateNewSection()) + : nullptr; + if (!AnimationTrack || !AnimationSection) + { + BakeError = TEXT("Failed to create the source skeletal animation track"); + } + else + { + AnimationSection->Params.Animation = SourceAnimation; + // Sequencer multiplies section PlayRate by the AnimSequence RateScale. + // Cancel the asset rate so the edit session sees the raw source timeline once. + AnimationSection->Params.PlayRate = RawTimelinePlayRate; + // Unreal's AnimSequence exporter samples the exact playback end as its final key. + // Keep one support frame on the source/rig sections so that sample evaluates the + // animation instead of falling outside every section and snapping to reference pose. + AnimationSection->SetRange(TRange( + FFrameNumber(StartFrame), FFrameNumber(EndFrameExclusive + 1))); + AnimationTrack->AddSection(*AnimationSection); + + FControlRigSequenceFocusGuard Focus(Sequence); + if (!Focus.IsReady()) + { + BakeError = TEXT("Could not focus the LevelSequence in Sequencer"); + } + else + { + const FMovieSceneObjectBindingID ObjectBinding( + UE::MovieScene::FFixedObjectBindingID(BindingGuid, MovieSceneSequenceID::Root)); + if (ULevelSequenceEditorBlueprintLibrary::GetBoundObjects(ObjectBinding).IsEmpty()) + { + BakeError = TEXT("Sequencer did not instantiate the skeletal mesh spawnable for baking"); + } + else + { + UAnimSeqExportOption* ExportOptions = NewObject(); + bBakeSucceeded = UControlRigSequencerEditorLibrary::BakeToControlRig( + GetEditorWorld(), Sequence, ControlRigClass, ExportOptions, + false, 0.001f, FMovieSceneBindingProxy(BindingGuid, Sequence), true); + if (!bBakeSucceeded) + { + BakeError = TEXT("Unreal failed to bake the source animation to the Control Rig"); + } + else + { + const TArray RigTracks = MovieScene->FindTracks( + UMovieSceneControlRigParameterTrack::StaticClass(), BindingGuid, NAME_None); + if (RigTracks.Num() != 1) + { + bBakeSucceeded = false; + BakeError = TEXT("Bake did not produce exactly one Control Rig track"); + } + else if (bLayered) + { + auto* RigTrack = Cast(RigTracks[0]); + if (!RigTrack || !UControlRigSequencerEditorLibrary::SetControlRigLayeredMode(RigTrack, true)) + { + bBakeSucceeded = false; + BakeError = TEXT("Unreal could not convert the Control Rig track to layered mode"); + } + else + { + // Layer conversion clears the baked Control Rig keys. The source track + // must be active so the now-empty rig section is an additive edit layer. + AnimationTrack->SetEvalDisabled(false); + } + } + if (bBakeSucceeded) + { + auto* RigTrack = Cast(RigTracks[0]); + const TArray RigSections = RigTrack ? RigTrack->GetAllSections() : TArray(); + if (!RigTrack || RigSections.Num() != 1 || !RigSections[0]) + { + bBakeSucceeded = false; + BakeError = TEXT("Bake did not produce exactly one Control Rig section"); + } + else + { + RigSections[0]->SetEndFrame(TRangeBound::Exclusive(FFrameNumber(EndFrameExclusive + 1))); + } + } + } + } + } + } + } + } + } + + if (!bBakeSucceeded) + { + if (bCreated) + { + UEditorAssetLibrary::DeleteAsset(PackagePath + TEXT("/") + AssetName); + } + return MCPError(BakeError.IsEmpty() ? TEXT("Failed to begin Control Rig edit") : BakeError); + } + + if (!UEditorAssetLibrary::SaveLoadedAsset(Sequence, false)) + { + if (bCreated) UEditorAssetLibrary::DeleteAsset(PackagePath + TEXT("/") + AssetName); + return MCPError(TEXT("Control Rig edit was created in memory but the LevelSequence could not be saved")); + } + FControlRigSequenceFocusGuard Focus(Sequence); + if (!Focus.IsReady()) return MCPError(TEXT("Control Rig edit was created but the LevelSequence could not be focused for inspection")); + FControlRigSequenceSession Session; + auto ResolveParams = MakeShared(); + ResolveParams->SetStringField(TEXT("sequencePath"), Sequence->GetPathName()); + ResolveParams->SetStringField(TEXT("bindingTag"), BindingTag); + if (!ControlRigSequencerResolveSession(ResolveParams, Session, Error)) return MCPError(Error); + auto Result = ControlRigSequencerSessionJson(Session); + Result->SetStringField(TEXT("sourceAnimationPath"), SourceAnimation->GetPathName()); + Result->SetStringField(TEXT("skeletalMeshPath"), SkeletalMesh->GetPathName()); + Result->SetStringField(TEXT("rigMode"), RigMode); + Result->SetBoolField(TEXT("layered"), bLayered); + MCPSetCreated(Result); + MCPSetDeleteAssetRollback(Result, Sequence->GetPathName()); + return MCPResult(Result); +#endif +} + +TSharedPtr FAnimationHandlers::ReadControlRigEdit(const TSharedPtr& Params) +{ +#if !UE_MCP_HAS_5_8_API + return ControlRigSequencerUnsupported(); +#else + FString SequencePath; + if (auto Error = RequireString(Params, TEXT("sequencePath"), SequencePath)) return Error; + ULevelSequence* Sequence = Cast(UEditorAssetLibrary::LoadAsset(SequencePath)); + if (!Sequence) return MCPError(FString::Printf(TEXT("LevelSequence not found: %s"), *SequencePath)); + FControlRigSequenceFocusGuard Focus(Sequence); + if (!Focus.IsReady()) return MCPError(TEXT("Could not focus the LevelSequence in Sequencer")); + + FControlRigSequenceSession Session; + FString Error; + if (!ControlRigSequencerResolveSession(Params, Session, Error)) return MCPError(Error); + EControlRigTransformSpace Space; + FString CanonicalSpace; + if (!ControlRigSequencerReadSpace(OptionalString(Params, TEXT("space"), TEXT("local")), Space, CanonicalSpace, Error)) + { + return MCPError(Error); + } + + TArray ControlNames; + TArray TransformControlNames; + TArray BoolControlNames; + TArray FloatControlNames; + TArray IntControlNames; + const TArray>* RequestedNames = nullptr; + if (Params->TryGetArrayField(TEXT("controlNames"), RequestedNames) && RequestedNames) + { + for (const TSharedPtr& Value : *RequestedNames) + { + if (!Value.IsValid() || Value->Type != EJson::String || Value->AsString().IsEmpty()) + { + return MCPError(TEXT("Every item in 'controlNames' must be a non-empty string")); + } + const FName Name(*Value->AsString()); + FRigControlElement* Control = Session.ControlRig->FindControl(Name); + if (!Control) + { + return MCPError(FString::Printf(TEXT("Control not found: %s"), *Name.ToString())); + } + ControlNames.AddUnique(Name); + if (ControlRigSequencerIsTransformControl(Control)) + { + TransformControlNames.AddUnique(Name); + } + else if (Control->Settings.ControlType == ERigControlType::Bool) + { + BoolControlNames.AddUnique(Name); + } + else if (ControlRigSequencerIsFloatControl(Control)) + { + FloatControlNames.AddUnique(Name); + } + else if (Control->Settings.ControlType == ERigControlType::Integer) + { + IntControlNames.AddUnique(Name); + } + else + { + return MCPError(FString::Printf(TEXT("Control type is not readable by this action: %s"), *Name.ToString())); + } + } + } + else + { + for (FRigControlElement* Control : Session.ControlRig->GetHierarchy()->GetControls()) + { + if (ControlRigSequencerIsTransformControl(Control)) + { + ControlNames.Add(Control->GetFName()); + TransformControlNames.Add(Control->GetFName()); + } + else if (Control->Settings.ControlType == ERigControlType::Bool) + { + ControlNames.Add(Control->GetFName()); + BoolControlNames.Add(Control->GetFName()); + } + else if (ControlRigSequencerIsFloatControl(Control)) + { + ControlNames.Add(Control->GetFName()); + FloatControlNames.Add(Control->GetFName()); + } + else if (Control->Settings.ControlType == ERigControlType::Integer) + { + ControlNames.Add(Control->GetFName()); + IntControlNames.Add(Control->GetFName()); + } + } + ControlNames.Sort(FNameLexicalLess()); + TransformControlNames.Sort(FNameLexicalLess()); + BoolControlNames.Sort(FNameLexicalLess()); + FloatControlNames.Sort(FNameLexicalLess()); + IntControlNames.Sort(FNameLexicalLess()); + } + if (ControlNames.IsEmpty()) return MCPError(TEXT("No readable controls were requested or available")); + + TArray Frames; + if (Params->HasField(TEXT("frame")) || Params->HasField(TEXT("frames"))) + { + if (!ControlRigSequencerReadFrames(Params, Frames, Error)) return MCPError(Error); + } + else + { + int32 Start = 0; + int32 EndExclusive = 0; + ControlRigSequencerDisplayRange(Session.MovieScene, Start, EndExclusive); + Frames.Add(FFrameNumber(Start)); + if (EndExclusive - 1 != Start) Frames.Add(FFrameNumber(EndExclusive - 1)); + } + + int32 Start = 0; + int32 EndExclusive = 0; + ControlRigSequencerDisplayRange(Session.MovieScene, Start, EndExclusive); + for (FFrameNumber Frame : Frames) + { + if (Frame.Value < Start || Frame.Value >= EndExclusive) + { + return MCPError(FString::Printf(TEXT("Frame %d is outside [%d, %d)"), Frame.Value, Start, EndExclusive)); + } + } + + TArray Values; + if (!TransformControlNames.IsEmpty()) + { + Values = UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, TransformControlNames, Frames, Space, EMovieSceneTimeUnit::DisplayRate); + if (Values.Num() != TransformControlNames.Num()) return MCPError(TEXT("Unreal could not evaluate every requested Control Rig transform")); + } + + auto Result = ControlRigSequencerSessionJson(Session); + if (RequestedNames) + { + TArray> RequestedControls = ControlRigSequencerControlsJson( + Session.ControlRig, &ControlNames); + Result->SetNumberField(TEXT("controlCount"), RequestedControls.Num()); + Result->SetArrayField(TEXT("controls"), RequestedControls); + } + Result->SetStringField(TEXT("space"), CanonicalSpace); + TArray> Samples; + for (const FArrayOfRigControlTransforms& Value : Values) + { + if (Value.Transforms.Num() != Frames.Num()) return MCPError(TEXT("Control Rig transform evaluation returned an incomplete frame set")); + auto ControlObject = MakeShared(); + ControlObject->SetStringField(TEXT("control"), Value.ControlName.ToString()); + TArray> FrameSamples; + for (int32 Index = 0; Index < Frames.Num(); ++Index) + { + auto FrameObject = MakeShared(); + FrameObject->SetNumberField(TEXT("frame"), Frames[Index].Value); + FrameObject->SetObjectField(TEXT("transform"), ControlRigSequencerTransformJson(Value.Transforms[Index])); + FrameSamples.Add(MakeShared(FrameObject)); + } + ControlObject->SetArrayField(TEXT("samples"), FrameSamples); + Samples.Add(MakeShared(ControlObject)); + } + for (const FName ControlName : BoolControlNames) + { + const TArray BoolValues = UControlRigSequencerEditorLibrary::GetLocalControlRigBools( + Session.Sequence, Session.ControlRig, ControlName, Frames, EMovieSceneTimeUnit::DisplayRate); + if (BoolValues.Num() != Frames.Num()) return MCPError(TEXT("Control Rig bool evaluation returned an incomplete frame set")); + auto ControlObject = MakeShared(); + ControlObject->SetStringField(TEXT("control"), ControlName.ToString()); + ControlObject->SetStringField(TEXT("valueType"), TEXT("bool")); + TArray> FrameSamples; + for (int32 Index = 0; Index < Frames.Num(); ++Index) + { + auto FrameObject = MakeShared(); + FrameObject->SetNumberField(TEXT("frame"), Frames[Index].Value); + FrameObject->SetBoolField(TEXT("value"), BoolValues[Index]); + FrameSamples.Add(MakeShared(FrameObject)); + } + ControlObject->SetArrayField(TEXT("samples"), FrameSamples); + Samples.Add(MakeShared(ControlObject)); + } + for (const FName ControlName : FloatControlNames) + { + const TArray FloatValues = UControlRigSequencerEditorLibrary::GetLocalControlRigFloats( + Session.Sequence, Session.ControlRig, ControlName, Frames, EMovieSceneTimeUnit::DisplayRate); + if (FloatValues.Num() != Frames.Num()) return MCPError(TEXT("Control Rig float evaluation returned an incomplete frame set")); + auto ControlObject = MakeShared(); + ControlObject->SetStringField(TEXT("control"), ControlName.ToString()); + ControlObject->SetStringField(TEXT("valueType"), TEXT("float")); + TArray> FrameSamples; + for (int32 Index = 0; Index < Frames.Num(); ++Index) + { + auto FrameObject = MakeShared(); + FrameObject->SetNumberField(TEXT("frame"), Frames[Index].Value); + FrameObject->SetNumberField(TEXT("value"), FloatValues[Index]); + FrameSamples.Add(MakeShared(FrameObject)); + } + ControlObject->SetArrayField(TEXT("samples"), FrameSamples); + Samples.Add(MakeShared(ControlObject)); + } + for (const FName ControlName : IntControlNames) + { + const TArray IntValues = UControlRigSequencerEditorLibrary::GetLocalControlRigInts( + Session.Sequence, Session.ControlRig, ControlName, Frames, EMovieSceneTimeUnit::DisplayRate); + if (IntValues.Num() != Frames.Num()) return MCPError(TEXT("Control Rig integer evaluation returned an incomplete frame set")); + const FRigControlElement* Control = Session.ControlRig->FindControl(ControlName); + const UEnum* ControlEnum = Control ? Control->Settings.ControlEnum.Get() : nullptr; + auto ControlObject = MakeShared(); + ControlObject->SetStringField(TEXT("control"), ControlName.ToString()); + ControlObject->SetStringField(TEXT("valueType"), ControlEnum ? TEXT("enum") : TEXT("int")); + TArray> FrameSamples; + for (int32 Index = 0; Index < Frames.Num(); ++Index) + { + auto FrameObject = MakeShared(); + FrameObject->SetNumberField(TEXT("frame"), Frames[Index].Value); + FrameObject->SetNumberField(TEXT("value"), IntValues[Index]); + const int32 EnumIndex = ControlRigSequencerFindEnumOption(ControlEnum, IntValues[Index]); + if (EnumIndex != INDEX_NONE) + { + FrameObject->SetStringField(TEXT("enumOption"), ControlEnum->GetNameStringByIndex(EnumIndex)); + } + FrameSamples.Add(MakeShared(FrameObject)); + } + ControlObject->SetArrayField(TEXT("samples"), FrameSamples); + Samples.Add(MakeShared(ControlObject)); + } + Result->SetArrayField(TEXT("samples"), Samples); + return MCPResult(Result); +#endif +} + +TSharedPtr FAnimationHandlers::ApplyControlRigEdits(const TSharedPtr& Params) +{ +#if !UE_MCP_HAS_5_8_API + return ControlRigSequencerUnsupported(); +#else + FString SequencePath; + if (auto Error = RequireString(Params, TEXT("sequencePath"), SequencePath)) return Error; + if (MCPIsProtectedAssetPath(SequencePath)) + { + return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *SequencePath)); + } + ULevelSequence* Sequence = Cast(UEditorAssetLibrary::LoadAsset(SequencePath)); + if (!Sequence) return MCPError(FString::Printf(TEXT("LevelSequence not found: %s"), *SequencePath)); + FControlRigSequenceFocusGuard Focus(Sequence); + if (!Focus.IsReady()) return MCPError(TEXT("Could not focus the LevelSequence in Sequencer")); + + FControlRigSequenceSession Session; + FString Error; + if (!ControlRigSequencerResolveSession(Params, Session, Error)) return MCPError(Error); + if (Session.Section->GetDoNotKey()) return MCPError(TEXT("The resolved Control Rig section is marked Do Not Key")); + const TArray>* Operations = nullptr; + if (!Params->TryGetArrayField(TEXT("operations"), Operations) || !Operations || Operations->IsEmpty()) + { + return MCPError(TEXT("'operations' must be a non-empty array")); + } + + int32 RangeStart = 0; + int32 RangeEndExclusive = 0; + ControlRigSequencerDisplayRange(Session.MovieScene, RangeStart, RangeEndExclusive); + TArray Prepared; + TArray PreparedContacts; + TSet WrittenKeys; + + for (int32 OperationIndex = 0; OperationIndex < Operations->Num(); ++OperationIndex) + { + const TSharedPtr Operation = (*Operations)[OperationIndex].IsValid() + ? (*Operations)[OperationIndex]->AsObject() : nullptr; + if (!Operation.IsValid()) return MCPError(FString::Printf(TEXT("operations[%d] must be an object"), OperationIndex)); + FString Op; + FString ControlString; + if (!Operation->TryGetStringField(TEXT("op"), Op) || Op.IsEmpty()) + return MCPError(FString::Printf(TEXT("operations[%d].op is required"), OperationIndex)); + if (!Operation->TryGetStringField(TEXT("control"), ControlString) || ControlString.IsEmpty()) + return MCPError(FString::Printf(TEXT("operations[%d].control is required"), OperationIndex)); + Op.ToLowerInline(); + const FName ControlName(*ControlString); + FRigControlElement* Control = Session.ControlRig->FindControl(ControlName); + if (!Control) return MCPError(FString::Printf(TEXT("Control not found: %s"), *ControlString)); + if (!Control->Settings.IsAnimatable()) return MCPError(FString::Printf(TEXT("Control is not animatable: %s"), *ControlString)); + const bool bSetOperation = Op == TEXT("set"); + const bool bSetKeysOperation = Op == TEXT("set_keys"); + const bool bOffsetOperation = Op == TEXT("offset"); + const bool bContactOperation = Op == TEXT("contact_lock"); + const bool bBoolOperation = Op == TEXT("set_bool"); + const bool bFloatOperation = Op == TEXT("set_float"); + const bool bIntOperation = Op == TEXT("set_int"); + if (!bSetOperation && !bSetKeysOperation && !bOffsetOperation && !bContactOperation + && !bBoolOperation && !bFloatOperation && !bIntOperation) + { + return MCPError(FString::Printf( + TEXT("operations[%d].op must be 'set_keys', 'set', 'offset', 'contact_lock', 'set_bool', 'set_float', or 'set_int'"), + OperationIndex)); + } + if (bBoolOperation) + { + if (Control->Settings.ControlType != ERigControlType::Bool) + return MCPError(FString::Printf(TEXT("Bool control not found: %s"), *ControlString)); + } + else if (bFloatOperation) + { + if (!ControlRigSequencerIsFloatControl(Control)) + return MCPError(FString::Printf(TEXT("Float control not found: %s"), *ControlString)); + } + else if (bIntOperation) + { + if (Control->Settings.ControlType != ERigControlType::Integer) + return MCPError(FString::Printf(TEXT("Integer control not found: %s"), *ControlString)); + } + else if (!ControlRigSequencerIsTransformControl(Control)) + { + return MCPError(FString::Printf(TEXT("Transform control not found: %s"), *ControlString)); + } + else if (bContactOperation && !ControlRigSequencerControlHasTranslation(Control->Settings.ControlType)) + { + return MCPError(FString::Printf( + TEXT("contact_lock driver control must support translation: %s"), *ControlString)); + } + + FControlRigPreparedWrite Write; + Write.Control = ControlName; + Write.Op = Op; + Write.ValueType = bBoolOperation ? EControlRigPreparedValueType::Bool + : bFloatOperation ? EControlRigPreparedValueType::Float + : bIntOperation ? EControlRigPreparedValueType::Integer + : EControlRigPreparedValueType::Transform; + TArray AbsoluteKeyTransforms; + if (Write.ValueType == EControlRigPreparedValueType::Transform) + { + if (bContactOperation) + { + if (Operation->HasField(TEXT("space"))) + return MCPError(FString::Printf(TEXT("operations[%d].contact_lock always uses component space"), OperationIndex)); + Write.Space = EControlRigTransformSpace::Global; + } + else + { + FString CanonicalSpace; + if (!ControlRigSequencerReadSpace(OptionalString(Operation, TEXT("space"), TEXT("local")), Write.Space, CanonicalSpace, Error)) + return MCPError(FString::Printf(TEXT("operations[%d]: %s"), OperationIndex, *Error)); + } + } + + if (bSetKeysOperation) + { + const TArray>* Keys = nullptr; + if (!Operation->TryGetArrayField(TEXT("keys"), Keys) || !Keys || Keys->IsEmpty()) + return MCPError(FString::Printf(TEXT("operations[%d].keys must be a non-empty array"), OperationIndex)); + int32 PreviousFrame = 0; + FQuat PreviousRotation = FQuat::Identity; + for (int32 KeyIndex = 0; KeyIndex < Keys->Num(); ++KeyIndex) + { + const TSharedPtr& KeyValue = (*Keys)[KeyIndex]; + if (!KeyValue.IsValid() || KeyValue->Type != EJson::Object) + return MCPError(FString::Printf(TEXT("operations[%d].keys[%d] must be an object"), OperationIndex, KeyIndex)); + const TSharedPtr KeyObject = KeyValue->AsObject(); + double FrameNumber = 0.0; + if (!KeyObject.IsValid() + || !KeyObject->TryGetNumberField(TEXT("frame"), FrameNumber) + || !FMath::IsFinite(FrameNumber) + || !FMath::IsNearlyEqual(FrameNumber, FMath::RoundToDouble(FrameNumber)) + || FrameNumber < static_cast(MIN_int32) + || FrameNumber > static_cast(MAX_int32)) + { + return MCPError(FString::Printf(TEXT("operations[%d].keys[%d].frame must be a 32-bit integer"), OperationIndex, KeyIndex)); + } + const int32 Frame = static_cast(FMath::RoundToInt(FrameNumber)); + if (KeyIndex > 0 && Frame <= PreviousFrame) + { + return MCPError(FString::Printf( + TEXT("operations[%d].keys frames must be strictly increasing and unique"), + OperationIndex)); + } + PreviousFrame = Frame; + + const TSharedPtr* TransformObject = nullptr; + if (!KeyObject->TryGetObjectField(TEXT("transform"), TransformObject) + || !TransformObject || !TransformObject->IsValid()) + { + return MCPError(FString::Printf(TEXT("operations[%d].keys[%d].transform must be an object"), OperationIndex, KeyIndex)); + } + if ((*TransformObject)->Values.Num() != 3 + || !(*TransformObject)->HasField(TEXT("translation")) + || !(*TransformObject)->HasField(TEXT("rotationQuaternion")) + || !(*TransformObject)->HasField(TEXT("scale"))) + { + return MCPError(FString::Printf( + TEXT("operations[%d].keys[%d].transform must contain exactly translation, rotationQuaternion and scale"), + OperationIndex, KeyIndex)); + } + FVector Translation; + FQuat Rotation; + FVector Scale; + if (!ControlRigSequencerReadVector(*TransformObject, TEXT("translation"), Translation, Error) + || !ControlRigSequencerReadNormalizedQuaternion(*TransformObject, Rotation, Error) + || !ControlRigSequencerReadVector(*TransformObject, TEXT("scale"), Scale, Error)) + { + return MCPError(FString::Printf(TEXT("operations[%d].keys[%d].transform: %s"), OperationIndex, KeyIndex, *Error)); + } + if (KeyIndex > 0 && (PreviousRotation | Rotation) < 0.0) + { + Rotation = FQuat(-Rotation.X, -Rotation.Y, -Rotation.Z, -Rotation.W); + } + PreviousRotation = Rotation; + Write.Frames.Add(FFrameNumber(Frame)); + AbsoluteKeyTransforms.Add(FTransform(Rotation, Translation, Scale)); + } + } + else if (bSetOperation || bBoolOperation || bFloatOperation || bIntOperation) + { + if (!ControlRigSequencerReadFrames(Operation, Write.Frames, Error)) + return MCPError(FString::Printf(TEXT("operations[%d]: %s"), OperationIndex, *Error)); + if (bBoolOperation && !Operation->TryGetBoolField(TEXT("value"), Write.BoolValue)) + return MCPError(FString::Printf(TEXT("operations[%d].value must be a boolean"), OperationIndex)); + if (bFloatOperation) + { + double Value = 0.0; + if (!Operation->TryGetNumberField(TEXT("value"), Value) || !FMath::IsFinite(Value)) + return MCPError(FString::Printf(TEXT("operations[%d].value must be a finite number"), OperationIndex)); + Write.FloatValue = static_cast(Value); + if (!FMath::IsFinite(Write.FloatValue)) + return MCPError(FString::Printf(TEXT("operations[%d].value is outside the float range"), OperationIndex)); + } + if (bIntOperation) + { + double Value = 0.0; + if (!Operation->TryGetNumberField(TEXT("value"), Value) + || !FMath::IsFinite(Value) + || !FMath::IsNearlyEqual(Value, FMath::RoundToDouble(Value)) + || Value < static_cast(MIN_int32) + || Value > static_cast(MAX_int32)) + { + return MCPError(FString::Printf(TEXT("operations[%d].value must be a 32-bit integer"), OperationIndex)); + } + Write.IntValue = static_cast(FMath::RoundToInt(Value)); + if (const UEnum* ControlEnum = Control->Settings.ControlEnum.Get()) + { + if (Write.IntValue < 0 || Write.IntValue > MAX_uint8) + { + return MCPError(FString::Printf( + TEXT("operations[%d].value is outside the byte range used by Sequencer enum channels"), + OperationIndex)); + } + if (!ControlRigSequencerIsValidEnumValue(ControlEnum, Write.IntValue)) + { + return MCPError(FString::Printf( + TEXT("operations[%d].value is not a selectable option in enum %s"), + OperationIndex, *ControlEnum->GetPathName())); + } + } + } + } + else if (bOffsetOperation || bContactOperation) + { + double StartNumber = 0.0; + double EndNumber = 0.0; + if (!Operation->TryGetNumberField(TEXT("startFrame"), StartNumber) + || !Operation->TryGetNumberField(TEXT("endFrame"), EndNumber) + || !FMath::IsFinite(StartNumber) || !FMath::IsFinite(EndNumber) + || !FMath::IsNearlyEqual(StartNumber, FMath::RoundToDouble(StartNumber)) + || !FMath::IsNearlyEqual(EndNumber, FMath::RoundToDouble(EndNumber)) + || StartNumber < static_cast(MIN_int32) || StartNumber > static_cast(MAX_int32) + || EndNumber < static_cast(MIN_int32) || EndNumber > static_cast(MAX_int32)) + { + return MCPError(FString::Printf(TEXT("operations[%d] requires integer startFrame and endFrame"), OperationIndex)); + } + const int32 Start = static_cast(FMath::RoundToInt(StartNumber)); + const int32 End = static_cast(FMath::RoundToInt(EndNumber)); + if (End < Start) return MCPError(FString::Printf(TEXT("operations[%d].endFrame must be at least startFrame"), OperationIndex)); + if (Start < RangeStart || End >= RangeEndExclusive) + { + return MCPError(FString::Printf( + TEXT("operations[%d] range [%d, %d] is outside [%d, %d)"), + OperationIndex, Start, End, RangeStart, RangeEndExclusive)); + } + const int64 FrameCount = static_cast(End) - static_cast(Start) + 1; + if (FrameCount > ControlRigSequencerMaxFrames) + { + return MCPError(FString::Printf( + TEXT("operations[%d] is limited to %d frames"), + OperationIndex, ControlRigSequencerMaxFrames)); + } + Write.Frames.Reserve(static_cast(FrameCount)); + for (int64 Frame = Start; Frame <= End; ++Frame) Write.Frames.Add(FFrameNumber(static_cast(Frame))); + } + + for (FFrameNumber Frame : Write.Frames) + { + if (Frame.Value < RangeStart || Frame.Value >= RangeEndExclusive) + return MCPError(FString::Printf(TEXT("operations[%d] frame %d is outside [%d, %d)"), OperationIndex, Frame.Value, RangeStart, RangeEndExclusive)); + } + if (!ControlRigSequencerRegisterWriteFrames(WrittenKeys, ControlName, Write.Frames, OperationIndex, Error)) + return MCPError(Error); + + if (Write.ValueType == EControlRigPreparedValueType::Bool) + { + const TArray Existing = UControlRigSequencerEditorLibrary::GetLocalControlRigBools( + Session.Sequence, Session.ControlRig, ControlName, Write.Frames, EMovieSceneTimeUnit::DisplayRate); + if (Existing.Num() != Write.Frames.Num()) + return MCPError(FString::Printf(TEXT("Could not sample %s before applying operations[%d]"), *ControlString, OperationIndex)); + } + else if (Write.ValueType == EControlRigPreparedValueType::Float) + { + const TArray Existing = UControlRigSequencerEditorLibrary::GetLocalControlRigFloats( + Session.Sequence, Session.ControlRig, ControlName, Write.Frames, EMovieSceneTimeUnit::DisplayRate); + if (Existing.Num() != Write.Frames.Num()) + return MCPError(FString::Printf(TEXT("Could not sample %s before applying operations[%d]"), *ControlString, OperationIndex)); + } + else if (Write.ValueType == EControlRigPreparedValueType::Integer) + { + const TArray Existing = UControlRigSequencerEditorLibrary::GetLocalControlRigInts( + Session.Sequence, Session.ControlRig, ControlName, Write.Frames, EMovieSceneTimeUnit::DisplayRate); + if (Existing.Num() != Write.Frames.Num()) + return MCPError(FString::Printf(TEXT("Could not sample %s before applying operations[%d]"), *ControlString, OperationIndex)); + } + else + { + if (bContactOperation) + { + const TSharedPtr* TargetObject = nullptr; + if (!Operation->TryGetObjectField(TEXT("target"), TargetObject) + || !TargetObject || !TargetObject->IsValid()) + { + return MCPError(FString::Printf(TEXT("operations[%d].target must be an object"), OperationIndex)); + } + const bool bTargetRotation = (*TargetObject)->HasField(TEXT("rotationQuaternion")); + if (!(*TargetObject)->HasField(TEXT("translation")) + || (*TargetObject)->Values.Num() != (bTargetRotation ? 2 : 1)) + { + return MCPError(FString::Printf( + TEXT("operations[%d].target must contain translation and optional rotationQuaternion only"), + OperationIndex)); + } + if (bTargetRotation && !ControlRigSequencerControlHasRotation(Control->Settings.ControlType)) + { + return MCPError(FString::Printf( + TEXT("contact_lock driver control must support rotation when target.rotationQuaternion is set: %s"), + *ControlString)); + } + + FVector TargetTranslation; + FQuat TargetRotation = FQuat::Identity; + if (!ControlRigSequencerReadVector(*TargetObject, TEXT("translation"), TargetTranslation, Error) + || (bTargetRotation && !ControlRigSequencerReadNormalizedQuaternion(*TargetObject, TargetRotation, Error))) + { + return MCPError(FString::Printf(TEXT("operations[%d].target: %s"), OperationIndex, *Error)); + } + + auto ReadNonNegativeFrameCount = [&](const TCHAR* Field, int32& OutValue) -> bool + { + OutValue = 0; + const TSharedPtr* Value = Operation->Values.Find(Field); + if (!Value || !Value->IsValid() || (*Value)->IsNull()) return true; + if ((*Value)->Type != EJson::Number) + { + Error = FString::Printf(TEXT("operations[%d].%s must be a non-negative integer"), OperationIndex, Field); + return false; + } + const double Number = (*Value)->AsNumber(); + if (!FMath::IsFinite(Number) + || !FMath::IsNearlyEqual(Number, FMath::RoundToDouble(Number)) + || Number < 0.0 || Number > static_cast(MAX_int32)) + { + Error = FString::Printf(TEXT("operations[%d].%s must be a non-negative integer"), OperationIndex, Field); + return false; + } + OutValue = static_cast(FMath::RoundToInt(Number)); + return true; + }; + int32 BlendIn = 0; + int32 BlendOut = 0; + if (!ReadNonNegativeFrameCount(TEXT("blendInFrames"), BlendIn) + || !ReadNonNegativeFrameCount(TEXT("blendOutFrames"), BlendOut)) + { + return MCPError(Error); + } + const int64 IntervalCount = static_cast(Write.Frames.Last().Value) + - static_cast(Write.Frames[0].Value); + if (static_cast(BlendIn) + static_cast(BlendOut) > IntervalCount) + { + return MCPError(FString::Printf( + TEXT("operations[%d] blends must leave at least one fully constrained frame"), + OperationIndex)); + } + + auto ReadTolerance = [&](const TCHAR* Field, double Default, double Maximum, double& OutValue) -> bool + { + OutValue = Default; + const TSharedPtr* Value = Operation->Values.Find(Field); + if (!Value || !Value->IsValid() || (*Value)->IsNull()) return true; + if ((*Value)->Type != EJson::Number) + { + Error = FString::Printf(TEXT("operations[%d].%s must be a positive finite number"), OperationIndex, Field); + return false; + } + const double Number = (*Value)->AsNumber(); + if (!FMath::IsFinite(Number) || Number <= 0.0 || Number > Maximum) + { + Error = FString::Printf( + TEXT("operations[%d].%s must be greater than zero and at most %.3f"), + OperationIndex, Field, Maximum); + return false; + } + OutValue = Number; + return true; + }; + double PositionToleranceCm = 0.1; + double RotationToleranceDegrees = 0.5; + if (!ReadTolerance(TEXT("positionToleranceCm"), 0.1, 100.0, PositionToleranceCm) + || !ReadTolerance(TEXT("rotationToleranceDegrees"), 0.5, 180.0, RotationToleranceDegrees)) + { + return MCPError(Error); + } + + FString DrivenReferenceString; + const bool bHasDrivenReference = Operation->HasField(TEXT("drivenReference")); + if (bHasDrivenReference + && (!Operation->TryGetStringField(TEXT("drivenReference"), DrivenReferenceString) + || DrivenReferenceString.IsEmpty())) + { + return MCPError(FString::Printf( + TEXT("operations[%d].drivenReference must be a non-empty bone or socket name"), + OperationIndex)); + } + const FName DrivenReference(*DrivenReferenceString); + + bool bUseFkRotationChain = false; + TArray FkChainBoneIndices; + TArray FkChainControls; + int32 FkChainWriteCount = 0; + if (bHasDrivenReference && Cast(Session.ControlRig)) + { + const UFKControlRig* FkRig = CastChecked(Session.ControlRig); + USkeletalMeshComponent* Component = ControlRigSequencerBoundSkeletalMesh(Session.ControlRig); + USkeletalMesh* Mesh = Component ? Component->GetSkeletalMeshAsset() : nullptr; + USkeleton* Skeleton = Mesh ? Mesh->GetSkeleton() : nullptr; + if (!Mesh || !Skeleton) + { + return MCPError(TEXT("FK contact_lock requires a bound skeletal mesh and skeleton")); + } + + const FReferenceSkeleton& ReferenceSkeleton = Mesh->GetRefSkeleton(); + const FName DriverBone = UFKControlRig::GetControlTargetName( + ControlName, ERigElementType::Bone); + const int32 DriverBoneIndex = ReferenceSkeleton.FindBoneIndex(DriverBone); + bool bDrivenReferenceIsSocket = false; + int32 DrivenBoneIndex = ReferenceSkeleton.FindBoneIndex(DrivenReference); + if (DrivenBoneIndex == INDEX_NONE) + { + const USkeletalMeshSocket* Socket = Component->GetSocketByName(DrivenReference); + bDrivenReferenceIsSocket = Socket != nullptr; + DrivenBoneIndex = Socket + ? ReferenceSkeleton.FindBoneIndex(Socket->BoneName) + : INDEX_NONE; + } + if (DriverBoneIndex == INDEX_NONE || DrivenBoneIndex == INDEX_NONE) + { + return MCPError(FString::Printf( + TEXT("FK contact_lock could not resolve driver %s and driven reference %s to bones"), + *ControlString, *DrivenReferenceString)); + } + + const int32 SkeletonDriverIndex = Skeleton->GetSkeletonBoneIndexFromMeshBoneIndex( + Mesh, DriverBoneIndex); + bUseFkRotationChain = SkeletonDriverIndex != INDEX_NONE + && Skeleton->GetBoneTranslationRetargetingMode(SkeletonDriverIndex) + == EBoneTranslationRetargetingMode::Skeleton; + if (bUseFkRotationChain) + { + if (FkRig->GetApplyMode() != EControlRigFKRigExecuteMode::Replace) + { + return MCPError( + TEXT("contact_lock_runtime_translation_unsupported: FK rotation-chain contact requires Replace apply mode")); + } + if (bDrivenReferenceIsSocket) + { + return MCPError(FString::Printf( + TEXT("contact_lock_runtime_translation_unsupported: FK rotation-chain contact currently requires a driven bone; socket %s requires an asset Control Rig"), + *DrivenReferenceString)); + } + for (int32 BoneIndex = DrivenBoneIndex; + BoneIndex != INDEX_NONE; + BoneIndex = ReferenceSkeleton.GetParentIndex(BoneIndex)) + { + FkChainBoneIndices.Add(BoneIndex); + if (BoneIndex == DriverBoneIndex) break; + } + if (FkChainBoneIndices.IsEmpty() || FkChainBoneIndices.Last() != DriverBoneIndex) + { + return MCPError(FString::Printf( + TEXT("FK contact_lock driver bone %s must be an ancestor of %s"), + *DriverBone.ToString(), + *ReferenceSkeleton.GetBoneName(DrivenBoneIndex).ToString())); + } + Algo::Reverse(FkChainBoneIndices); + if (FkChainBoneIndices.Num() < 2) + { + return MCPError(FString::Printf( + TEXT("contact_lock_runtime_translation_unsupported: FK bone %s ignores animation translation and has no descendant rotation chain"), + *DriverBone.ToString())); + } + for (const int32 BoneIndex : FkChainBoneIndices) + { + const FName ChainControl = UFKControlRig::GetControlName( + ReferenceSkeleton.GetBoneName(BoneIndex), ERigElementType::Bone); + const FRigControlElement* ChainElement = Session.ControlRig->FindControl(ChainControl); + if (!ChainElement || !ChainElement->Settings.IsAnimatable() + || !ControlRigSequencerControlHasRotation(ChainElement->Settings.ControlType)) + { + return MCPError(FString::Printf( + TEXT("FK contact_lock rotation-chain control is unavailable: %s"), + *ChainControl.ToString())); + } + FkChainControls.Add(ChainControl); + } + // A position-only contact needs rotations through the end bone's parent. + // Key the end control only when the caller explicitly requests orientation. + FkChainWriteCount = bTargetRotation + ? FkChainControls.Num() + : FkChainControls.Num() - 1; + for (int32 ChainIndex = 1; ChainIndex < FkChainWriteCount; ++ChainIndex) + { + if (!ControlRigSequencerRegisterWriteFrames( + WrittenKeys, FkChainControls[ChainIndex], Write.Frames, OperationIndex, Error)) + { + return MCPError(Error); + } + } + } + } + + TArray StabilizerNames; + const TSharedPtr* StabilizerValue = Operation->Values.Find(TEXT("stabilizeControls")); + if (StabilizerValue && StabilizerValue->IsValid() && !(*StabilizerValue)->IsNull()) + { + const TArray>* StabilizerValues = nullptr; + if (!Operation->TryGetArrayField(TEXT("stabilizeControls"), StabilizerValues) || !StabilizerValues) + { + return MCPError(FString::Printf(TEXT("operations[%d].stabilizeControls must be an array"), OperationIndex)); + } + if (bUseFkRotationChain && !StabilizerValues->IsEmpty()) + { + return MCPError(FString::Printf( + TEXT("operations[%d] cannot combine FK rotation-chain contact with stabilizer controls"), + OperationIndex)); + } + if (StabilizerValues->Num() > 8) + { + return MCPError(FString::Printf(TEXT("operations[%d] supports at most 8 stabilizer controls"), OperationIndex)); + } + for (int32 StabilizerIndex = 0; StabilizerIndex < StabilizerValues->Num(); ++StabilizerIndex) + { + const TSharedPtr& Value = (*StabilizerValues)[StabilizerIndex]; + if (!Value.IsValid() || Value->Type != EJson::String || Value->AsString().IsEmpty()) + { + return MCPError(FString::Printf( + TEXT("operations[%d].stabilizeControls[%d] must be a non-empty string"), + OperationIndex, StabilizerIndex)); + } + const FName StabilizerName(*Value->AsString()); + if (StabilizerName == ControlName || StabilizerNames.Contains(StabilizerName)) + { + return MCPError(FString::Printf( + TEXT("operations[%d] stabilizers must be unique and cannot include the driver control"), + OperationIndex)); + } + FRigControlElement* Stabilizer = Session.ControlRig->FindControl(StabilizerName); + if (!Stabilizer || !Stabilizer->Settings.IsAnimatable() + || !ControlRigSequencerIsTransformControl(Stabilizer) + || (!ControlRigSequencerControlHasTranslation(Stabilizer->Settings.ControlType) + && !ControlRigSequencerControlHasRotation(Stabilizer->Settings.ControlType))) + { + return MCPError(FString::Printf( + TEXT("contact_lock stabilizer must be an animatable position and/or rotation control: %s"), + *StabilizerName.ToString())); + } + if (!ControlRigSequencerRegisterWriteFrames( + WrittenKeys, StabilizerName, Write.Frames, OperationIndex, Error)) + { + return MCPError(Error); + } + StabilizerNames.Add(StabilizerName); + } + } + + const int32 ContactControlCount = bUseFkRotationChain + ? FkChainWriteCount + : 1; + const int64 CellCount = static_cast(Write.Frames.Num()) + * static_cast(StabilizerNames.Num() + ContactControlCount); + if (CellCount > ControlRigSequencerMaxFrames) + { + return MCPError(FString::Printf( + TEXT("operations[%d] is limited to %d contact control-frame cells"), + OperationIndex, ControlRigSequencerMaxFrames)); + } + + TArray ContactControls{ControlName}; + ContactControls.Append(StabilizerNames); + const TArray Existing = + UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, ContactControls, Write.Frames, + EControlRigTransformSpace::Global, EMovieSceneTimeUnit::DisplayRate); + if (Existing.Num() != ContactControls.Num()) + { + return MCPError(FString::Printf( + TEXT("Could not sample every contact_lock control before operations[%d]"), + OperationIndex)); + } + TMap ExistingByControl; + for (const FArrayOfRigControlTransforms& Values : Existing) + { + if (Values.Transforms.Num() != Write.Frames.Num()) + { + return MCPError(FString::Printf( + TEXT("Could not sample every contact_lock frame before operations[%d]"), + OperationIndex)); + } + ExistingByControl.Add(Values.ControlName, &Values); + } + const FArrayOfRigControlTransforms* const* DriverValues = ExistingByControl.Find(ControlName); + if (!DriverValues || !*DriverValues) + { + return MCPError(FString::Printf(TEXT("Could not sample contact_lock driver %s"), *ControlString)); + } + Write.Before = (*DriverValues)->Transforms; + Write.After.SetNum(Write.Frames.Num()); + + TArray> FkSourceChainGlobals; + if (bUseFkRotationChain) + { + const TArray GlobalChainValues = + UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, FkChainControls, Write.Frames, + EControlRigTransformSpace::Global, EMovieSceneTimeUnit::DisplayRate); + if (GlobalChainValues.Num() != FkChainControls.Num()) + { + return MCPError(FString::Printf( + TEXT("Could not sample every FK contact rotation-chain control before operations[%d]"), + OperationIndex)); + } + TMap GlobalValuesByControl; + for (const FArrayOfRigControlTransforms& Values : GlobalChainValues) + { + if (Values.Transforms.Num() != Write.Frames.Num()) + { + return MCPError(FString::Printf( + TEXT("Could not sample every FK contact rotation-chain frame before operations[%d]"), + OperationIndex)); + } + GlobalValuesByControl.Add(Values.ControlName, &Values); + } + FkSourceChainGlobals.SetNum(FkChainControls.Num()); + for (int32 ChainIndex = 0; ChainIndex < FkChainControls.Num(); ++ChainIndex) + { + const FArrayOfRigControlTransforms* const* Values = + GlobalValuesByControl.Find(FkChainControls[ChainIndex]); + if (!Values || !*Values) + { + return MCPError(FString::Printf( + TEXT("Could not sample FK contact rotation-chain control %s"), + *FkChainControls[ChainIndex].ToString())); + } + FkSourceChainGlobals[ChainIndex] = (*Values)->Transforms; + } + } + + TArray SubjectBefore = Write.Before; + if (bUseFkRotationChain) + { + // FK control globals include their initial offset and coincide with the + // evaluated bone component transforms. Use them so an existing rig layer + // is part of the solve instead of resampling only the source animation. + SubjectBefore = FkSourceChainGlobals.Last(); + } + else if (bHasDrivenReference + && !ControlRigSequencerSampleReferenceTransforms( + Session, DrivenReference, Write.Frames, SubjectBefore, Error)) + { + return MCPError(FString::Printf(TEXT("operations[%d]: %s"), OperationIndex, *Error)); + } + + FControlRigPreparedContactQA ContactQA; + ContactQA.OperationIndex = OperationIndex; + ContactQA.Control = ControlName; + ContactQA.DrivenReference = DrivenReference; + ContactQA.ControlType = Control->Settings.ControlType; + ContactQA.bHasDrivenReference = bHasDrivenReference; + ContactQA.bCheckRotation = bTargetRotation; + ContactQA.PositionToleranceCm = PositionToleranceCm; + ContactQA.RotationToleranceDegrees = RotationToleranceDegrees; + ContactQA.Frames = Write.Frames; + ContactQA.ExpectedSubject.SetNum(Write.Frames.Num()); + + TArray Weights; + Weights.SetNum(Write.Frames.Num()); + for (int32 Index = 0; Index < Write.Frames.Num(); ++Index) + { + const double Weight = ControlRigSequencerContactWeight( + Index, Write.Frames.Num(), BlendIn, BlendOut); + Weights[Index] = Weight; + if (Weight >= 1.0 - UE_DOUBLE_SMALL_NUMBER) ++ContactQA.FullWeightFrameCount; + + FTransform SubjectTarget = SubjectBefore[Index]; + SubjectTarget.SetTranslation(TargetTranslation); + if (bTargetRotation) SubjectTarget.SetRotation(TargetRotation); + ContactQA.ExpectedSubject[Index] = ControlRigSequencerBlendContactTransform( + SubjectBefore[Index], SubjectTarget, Weight, bTargetRotation); + + if (bUseFkRotationChain) + { + continue; + } + if (bHasDrivenReference) + { + const FTransform SubjectRelativeToDriver = + SubjectBefore[Index].GetRelativeTransform(Write.Before[Index]); + FTransform DriverTarget = SubjectRelativeToDriver.GetRelativeTransformReverse( + ContactQA.ExpectedSubject[Index]); + DriverTarget.SetScale3D(Write.Before[Index].GetScale3D()); + if (!ControlRigSequencerControlHasRotation(Control->Settings.ControlType)) + { + DriverTarget.SetRotation(Write.Before[Index].GetRotation()); + } + if (DriverTarget.ContainsNaN()) + { + return MCPError(FString::Printf( + TEXT("operations[%d] produced an invalid driver transform at frame %d"), + OperationIndex, Write.Frames[Index].Value)); + } + Write.After[Index] = DriverTarget; + } + else + { + Write.After[Index] = ContactQA.ExpectedSubject[Index]; + } + } + + TArray FkChainWrites; + if (bUseFkRotationChain) + { + ContactQA.bUsedFkRotationChain = true; + const TArray LocalControlValues = + UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, FkChainControls, Write.Frames, + EControlRigTransformSpace::Local, EMovieSceneTimeUnit::DisplayRate); + if (LocalControlValues.Num() != FkChainControls.Num()) + { + return MCPError(FString::Printf( + TEXT("Could not sample every FK contact rotation-chain control before operations[%d]"), + OperationIndex)); + } + TMap LocalValuesByControl; + for (const FArrayOfRigControlTransforms& Values : LocalControlValues) + { + if (Values.Transforms.Num() != Write.Frames.Num()) + { + return MCPError(FString::Printf( + TEXT("Could not sample every FK contact rotation-chain frame before operations[%d]"), + OperationIndex)); + } + LocalValuesByControl.Add(Values.ControlName, &Values); + } + + FkChainWrites.SetNum(FkChainWriteCount); + TArray ControlOffsets; + ControlOffsets.SetNum(FkChainWriteCount); + for (int32 ChainIndex = 0; ChainIndex < FkChainWriteCount; ++ChainIndex) + { + const FArrayOfRigControlTransforms* const* LocalValues = + LocalValuesByControl.Find(FkChainControls[ChainIndex]); + FRigControlElement* ChainControl = Session.ControlRig->FindControl(FkChainControls[ChainIndex]); + if (!LocalValues || !*LocalValues || !ChainControl) + { + return MCPError(FString::Printf( + TEXT("Could not prepare FK contact rotation-chain control %s"), + *FkChainControls[ChainIndex].ToString())); + } + FControlRigPreparedWrite& ChainWrite = FkChainWrites[ChainIndex]; + ChainWrite.Control = FkChainControls[ChainIndex]; + ChainWrite.Op = Op; + ChainWrite.Space = EControlRigTransformSpace::Local; + ChainWrite.ValueType = EControlRigPreparedValueType::Transform; + ChainWrite.Frames = Write.Frames; + ChainWrite.Before = (*LocalValues)->Transforms; + ChainWrite.After.SetNum(Write.Frames.Num()); + ControlOffsets[ChainIndex] = Session.ControlRig->GetHierarchy()->GetControlOffsetTransform( + ChainControl, ERigTransformType::InitialLocal); + } + + TArray PredictedSubjects; + PredictedSubjects.SetNum(Write.Frames.Num()); + for (int32 FrameIndex = 0; FrameIndex < Write.Frames.Num(); ++FrameIndex) + { + TArray SourceGlobals; + SourceGlobals.Reserve(FkChainBoneIndices.Num()); + for (const TArray& BoneSamples : FkSourceChainGlobals) + { + SourceGlobals.Add(BoneSamples[FrameIndex]); + } + + const FTransform SubjectRelativeToEnd = + SubjectBefore[FrameIndex].GetRelativeTransform(SourceGlobals.Last()); + const FTransform TargetEnd = SubjectRelativeToEnd.GetRelativeTransformReverse( + ContactQA.ExpectedSubject[FrameIndex]); + TArray SolvedGlobals; + double SolverPositionErrorCm = 0.0; + if (!ControlRigSequencerSolveRotationChain( + SourceGlobals, TargetEnd, bTargetRotation, SolvedGlobals, SolverPositionErrorCm) + || !FMath::IsFinite(SolverPositionErrorCm)) + { + return MCPError(FString::Printf( + TEXT("operations[%d] could not solve the FK contact rotation chain at frame %d"), + OperationIndex, Write.Frames[FrameIndex].Value)); + } + + const FTransform SourceDriverLocal = + FkChainWrites[0].Before[FrameIndex] * ControlOffsets[0]; + FTransform SourceParent = + SourceDriverLocal.GetRelativeTransformReverse(SourceGlobals[0]); + FTransform DesiredParent = SourceParent; + for (int32 ChainIndex = 0; ChainIndex < FkChainBoneIndices.Num(); ++ChainIndex) + { + const FTransform SourceLocal = SourceGlobals[ChainIndex].GetRelativeTransform(SourceParent); + FTransform DesiredLocal = SourceLocal; + if (ChainIndex < FkChainWriteCount) + { + DesiredLocal = SolvedGlobals[ChainIndex].GetRelativeTransform(DesiredParent); + // Skeleton-retargeted FK translations are discarded during playback. Keep the + // source local lengths and express the contact correction in rotations only. + DesiredLocal.SetTranslation(SourceLocal.GetTranslation()); + DesiredLocal.SetScale3D(SourceLocal.GetScale3D()); + DesiredLocal.NormalizeRotation(); + FkChainWrites[ChainIndex].After[FrameIndex] = + DesiredLocal.GetRelativeTransform(ControlOffsets[ChainIndex]); + } + const FTransform DesiredGlobal = DesiredLocal * DesiredParent; + SourceParent = SourceGlobals[ChainIndex]; + DesiredParent = DesiredGlobal; + SolvedGlobals[ChainIndex] = DesiredGlobal; + } + PredictedSubjects[FrameIndex] = SubjectRelativeToEnd * SolvedGlobals.Last(); + } + + ControlRigSequencerMeasureContact( + Write.Frames, ContactQA.ExpectedSubject, PredictedSubjects, + true, bTargetRotation, ContactQA.Metrics); + if (ContactQA.Metrics.MaxPositionErrorCm > PositionToleranceCm + || (bTargetRotation + && ContactQA.Metrics.MaxRotationErrorDegrees > RotationToleranceDegrees)) + { + return MCPError(FString::Printf( + TEXT("contact_constraint_tolerance_exceeded: operations[%d] FK rotation-chain residual was %.4f cm at frame %d and %.4f degrees at frame %d"), + OperationIndex, + ContactQA.Metrics.MaxPositionErrorCm, + ContactQA.Metrics.WorstPositionFrame, + ContactQA.Metrics.MaxRotationErrorDegrees, + ContactQA.Metrics.WorstRotationFrame)); + } + } + + for (const FName StabilizerName : StabilizerNames) + { + const FArrayOfRigControlTransforms* const* StabilizerValues = ExistingByControl.Find(StabilizerName); + FRigControlElement* Stabilizer = Session.ControlRig->FindControl(StabilizerName); + if (!StabilizerValues || !*StabilizerValues || !Stabilizer) + { + return MCPError(FString::Printf(TEXT("Could not prepare stabilizer %s"), *StabilizerName.ToString())); + } + FControlRigPreparedWrite StabilizerWrite; + StabilizerWrite.Control = StabilizerName; + StabilizerWrite.Op = Op; + StabilizerWrite.Space = EControlRigTransformSpace::Global; + StabilizerWrite.ValueType = EControlRigPreparedValueType::Transform; + StabilizerWrite.Frames = Write.Frames; + StabilizerWrite.Before = (*StabilizerValues)->Transforms; + StabilizerWrite.After.SetNum(Write.Frames.Num()); + const FTransform Anchor = StabilizerWrite.Before[0]; + const bool bStabilizeRotation = + ControlRigSequencerControlHasRotation(Stabilizer->Settings.ControlType); + for (int32 Index = 0; Index < Write.Frames.Num(); ++Index) + { + StabilizerWrite.After[Index] = ControlRigSequencerBlendContactTransform( + StabilizerWrite.Before[Index], Anchor, Weights[Index], bStabilizeRotation); + } + + FControlRigContactStabilizerQA StabilizerQA; + StabilizerQA.Control = StabilizerName; + StabilizerQA.ControlType = Stabilizer->Settings.ControlType; + StabilizerQA.Expected = StabilizerWrite.After; + ContactQA.Stabilizers.Add(MoveTemp(StabilizerQA)); + Prepared.Add(MoveTemp(StabilizerWrite)); + } + + PreparedContacts.Add(MoveTemp(ContactQA)); + if (bUseFkRotationChain) + { + Prepared.Append(MoveTemp(FkChainWrites)); + } + else + { + Prepared.Add(MoveTemp(Write)); + } + continue; + } + + const TArray OneControl{ControlName}; + const TArray Existing = UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, OneControl, Write.Frames, Write.Space, EMovieSceneTimeUnit::DisplayRate); + if (Existing.Num() != 1 || Existing[0].Transforms.Num() != Write.Frames.Num()) + return MCPError(FString::Printf(TEXT("Could not sample %s before applying operations[%d]"), *ControlString, OperationIndex)); + Write.Before = Existing[0].Transforms; + Write.After = Write.Before; + + if (Op == TEXT("set")) + { + const TArray>* TransformValues = nullptr; + const TSharedPtr* SingleTransform = nullptr; + if (Operation->TryGetArrayField(TEXT("transforms"), TransformValues) && TransformValues) + { + if (TransformValues->Num() != Write.Frames.Num()) + return MCPError(FString::Printf(TEXT("operations[%d].transforms must match the frame count"), OperationIndex)); + for (int32 Index = 0; Index < TransformValues->Num(); ++Index) + { + const TSharedPtr TransformObject = (*TransformValues)[Index].IsValid() + ? (*TransformValues)[Index]->AsObject() : nullptr; + FControlRigTransformPatch Patch; + if (!ControlRigSequencerReadTransformPatch(TransformObject, true, Patch, Error)) + return MCPError(FString::Printf(TEXT("operations[%d].transforms[%d]: %s"), OperationIndex, Index, *Error)); + Write.After[Index] = ControlRigSequencerApplySetPatch(Write.Before[Index], Patch); + } + } + else if (Operation->TryGetObjectField(TEXT("transform"), SingleTransform) && SingleTransform && SingleTransform->IsValid()) + { + FControlRigTransformPatch Patch; + if (!ControlRigSequencerReadTransformPatch(*SingleTransform, true, Patch, Error)) + return MCPError(FString::Printf(TEXT("operations[%d].transform: %s"), OperationIndex, *Error)); + for (int32 Index = 0; Index < Write.After.Num(); ++Index) + Write.After[Index] = ControlRigSequencerApplySetPatch(Write.Before[Index], Patch); + } + else + { + return MCPError(FString::Printf(TEXT("operations[%d] requires 'transform' or 'transforms'"), OperationIndex)); + } + } + else if (Op == TEXT("set_keys")) + { + if (AbsoluteKeyTransforms.Num() != Write.Frames.Num()) + return MCPError(FString::Printf(TEXT("operations[%d].keys could not be prepared"), OperationIndex)); + Write.After = MoveTemp(AbsoluteKeyTransforms); + } + else + { + FControlRigTransformPatch Patch; + if (!ControlRigSequencerReadTransformPatch(Operation, true, Patch, Error)) + return MCPError(FString::Printf(TEXT("operations[%d]: %s"), OperationIndex, *Error)); + if (!ControlRigSequencerPatchAffectsControl(Patch, Control->Settings.ControlType)) + { + return MCPError(FString::Printf( + TEXT("operations[%d] does not change a channel supported by %s"), + OperationIndex, *ControlString)); + } + const int32 BlendIn = FMath::Max(0, OptionalInt(Operation, TEXT("blendInFrames"), 0)); + const int32 BlendOut = FMath::Max(0, OptionalInt(Operation, TEXT("blendOutFrames"), 0)); + for (int32 Index = 0; Index < Write.After.Num(); ++Index) + { + double Weight = 1.0; + if (BlendIn > 0) Weight = FMath::Min(Weight, static_cast(Index) / static_cast(BlendIn)); + if (BlendOut > 0) Weight = FMath::Min(Weight, static_cast(Write.After.Num() - 1 - Index) / static_cast(BlendOut)); + Write.After[Index] = ControlRigSequencerApplyOffset(Write.Before[Index], Patch, FMath::Clamp(Weight, 0.0, 1.0), Write.Space); + } + } + } + Prepared.Add(MoveTemp(Write)); + } + + // All controls, frames and payloads have been resolved and sampled. Only now + // do we create keys in the LevelSequence. + bool bApplyFailed = false; + FString ApplyError; + { + const FScopedTransaction Transaction(NSLOCTEXT("UE_MCP", "ApplyControlRigEdits", "Apply Control Rig Edits")); + Session.Sequence->Modify(); + Session.MovieScene->Modify(); + Session.Section->Modify(); + for (const FControlRigPreparedWrite& Write : Prepared) + { + if (Write.ValueType == EControlRigPreparedValueType::Bool) + { + TArray Values; + Values.Init(Write.BoolValue, Write.Frames.Num()); + Session.Track->SetSectionToKey(Session.Section, Write.Control); + UControlRigSequencerEditorLibrary::SetLocalControlRigBools( + Session.Sequence, Session.ControlRig, Write.Control, Write.Frames, Values, + EMovieSceneTimeUnit::DisplayRate); + const TArray Actual = UControlRigSequencerEditorLibrary::GetLocalControlRigBools( + Session.Sequence, Session.ControlRig, Write.Control, Write.Frames, + EMovieSceneTimeUnit::DisplayRate); + if (Actual != Values) + { + bApplyFailed = true; + ApplyError = FString::Printf(TEXT("Unreal failed while applying the prevalidated '%s' edit to %s"), *Write.Op, *Write.Control.ToString()); + break; + } + continue; + } + if (Write.ValueType == EControlRigPreparedValueType::Float) + { + TArray Values; + Values.Init(Write.FloatValue, Write.Frames.Num()); + Session.Track->SetSectionToKey(Session.Section, Write.Control); + UControlRigSequencerEditorLibrary::SetLocalControlRigFloats( + Session.Sequence, Session.ControlRig, Write.Control, Write.Frames, Values, + EMovieSceneTimeUnit::DisplayRate); + const TArray Actual = UControlRigSequencerEditorLibrary::GetLocalControlRigFloats( + Session.Sequence, Session.ControlRig, Write.Control, Write.Frames, + EMovieSceneTimeUnit::DisplayRate); + bool bMatches = Actual.Num() == Values.Num(); + for (int32 Index = 0; bMatches && Index < Values.Num(); ++Index) + { + bMatches = FMath::IsNearlyEqual(Actual[Index], Values[Index]); + } + if (!bMatches) + { + bApplyFailed = true; + ApplyError = FString::Printf(TEXT("Unreal failed while applying the prevalidated '%s' edit to %s"), *Write.Op, *Write.Control.ToString()); + break; + } + continue; + } + if (Write.ValueType == EControlRigPreparedValueType::Integer) + { + TArray Values; + Values.Init(Write.IntValue, Write.Frames.Num()); + Session.Track->SetSectionToKey(Session.Section, Write.Control); + UControlRigSequencerEditorLibrary::SetLocalControlRigInts( + Session.Sequence, Session.ControlRig, Write.Control, Write.Frames, Values, + EMovieSceneTimeUnit::DisplayRate); + const TArray Actual = UControlRigSequencerEditorLibrary::GetLocalControlRigInts( + Session.Sequence, Session.ControlRig, Write.Control, Write.Frames, + EMovieSceneTimeUnit::DisplayRate); + if (Actual != Values) + { + bApplyFailed = true; + ApplyError = FString::Printf(TEXT("Unreal failed while applying the prevalidated '%s' edit to %s"), *Write.Op, *Write.Control.ToString()); + break; + } + continue; + } + FArrayOfRigControlTransforms Values; + Values.ControlName = Write.Control; + Values.Transforms = Write.After; + Session.Track->SetSectionToKey(Session.Section, Write.Control); + if (!UControlRigSequencerEditorLibrary::BatchSetControlTransforms( + Session.Sequence, Session.ControlRig, {Values}, Write.Frames, Write.Space, + Session.Section, EMovieSceneTimeUnit::DisplayRate)) + { + bApplyFailed = true; + ApplyError = FString::Printf(TEXT("Unreal failed while applying the prevalidated '%s' edit to %s"), *Write.Op, *Write.Control.ToString()); + break; + } + { + const TArray OneControl{Write.Control}; + const TArray Actual = UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, OneControl, Write.Frames, Write.Space, + EMovieSceneTimeUnit::DisplayRate); + const FRigControlElement* Control = Session.ControlRig->FindControl(Write.Control); + bool bMatches = Control && Actual.Num() == 1 && Actual[0].Transforms.Num() == Write.After.Num(); + for (int32 Index = 0; bMatches && Index < Write.After.Num(); ++Index) + { + bMatches = ControlRigSequencerTransformMatches( + Write.After[Index], Actual[0].Transforms[Index], Control->Settings.ControlType); + } + if (!bMatches) + { + bApplyFailed = true; + ApplyError = FString::Printf(TEXT("Unreal readback did not match the '%s' keys applied to %s"), *Write.Op, *Write.Control.ToString()); + break; + } + } + } + + if (!bApplyFailed) + { + for (FControlRigPreparedContactQA& Contact : PreparedContacts) + { + if (!Contact.bHasDrivenReference) + { + const TArray Actual = + UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, {Contact.Control}, Contact.Frames, + EControlRigTransformSpace::Global, EMovieSceneTimeUnit::DisplayRate); + if (Actual.Num() != 1 || Actual[0].Transforms.Num() != Contact.Frames.Num()) + { + bApplyFailed = true; + ApplyError = FString::Printf( + TEXT("Could not perform final contact_lock readback for %s"), + *Contact.Control.ToString()); + break; + } + ControlRigSequencerMeasureContact( + Contact.Frames, Contact.ExpectedSubject, Actual[0].Transforms, + true, Contact.bCheckRotation, Contact.Metrics); + if (Contact.Metrics.MaxPositionErrorCm > Contact.PositionToleranceCm + || (Contact.bCheckRotation + && Contact.Metrics.MaxRotationErrorDegrees > Contact.RotationToleranceDegrees)) + { + bApplyFailed = true; + ApplyError = FString::Printf( + TEXT("contact_constraint_tolerance_exceeded: operations[%d] %s residual was %.4f cm at frame %d and %.4f degrees at frame %d"), + Contact.OperationIndex, + *Contact.Control.ToString(), + Contact.Metrics.MaxPositionErrorCm, + Contact.Metrics.WorstPositionFrame, + Contact.Metrics.MaxRotationErrorDegrees, + Contact.Metrics.WorstRotationFrame); + break; + } + } + + if (!Contact.Stabilizers.IsEmpty()) + { + TArray StabilizerNames; + for (const FControlRigContactStabilizerQA& Stabilizer : Contact.Stabilizers) + { + StabilizerNames.Add(Stabilizer.Control); + } + const TArray ActualStabilizers = + UControlRigSequencerEditorLibrary::BatchGetControlTransforms( + Session.Sequence, Session.ControlRig, StabilizerNames, Contact.Frames, + EControlRigTransformSpace::Global, EMovieSceneTimeUnit::DisplayRate); + if (ActualStabilizers.Num() != Contact.Stabilizers.Num()) + { + bApplyFailed = true; + ApplyError = FString::Printf( + TEXT("Could not perform final contact_lock stabilizer readback for operations[%d]"), + Contact.OperationIndex); + break; + } + TMap ActualByControl; + for (const FArrayOfRigControlTransforms& Values : ActualStabilizers) + { + ActualByControl.Add(Values.ControlName, &Values); + } + for (FControlRigContactStabilizerQA& Stabilizer : Contact.Stabilizers) + { + const FArrayOfRigControlTransforms* const* ActualValues = + ActualByControl.Find(Stabilizer.Control); + if (!ActualValues || !*ActualValues + || (*ActualValues)->Transforms.Num() != Contact.Frames.Num()) + { + bApplyFailed = true; + ApplyError = FString::Printf( + TEXT("Could not perform final contact_lock readback for stabilizer %s"), + *Stabilizer.Control.ToString()); + break; + } + const bool bCheckPosition = + ControlRigSequencerControlHasTranslation(Stabilizer.ControlType); + const bool bCheckRotation = + ControlRigSequencerControlHasRotation(Stabilizer.ControlType); + ControlRigSequencerMeasureContact( + Contact.Frames, Stabilizer.Expected, (*ActualValues)->Transforms, + bCheckPosition, bCheckRotation, Stabilizer.Metrics); + if ((bCheckPosition + && Stabilizer.Metrics.MaxPositionErrorCm > Contact.PositionToleranceCm) + || (bCheckRotation + && Stabilizer.Metrics.MaxRotationErrorDegrees > Contact.RotationToleranceDegrees)) + { + bApplyFailed = true; + ApplyError = FString::Printf( + TEXT("contact_constraint_tolerance_exceeded: operations[%d] stabilizer %s residual was %.4f cm and %.4f degrees"), + Contact.OperationIndex, *Stabilizer.Control.ToString(), + Stabilizer.Metrics.MaxPositionErrorCm, + Stabilizer.Metrics.MaxRotationErrorDegrees); + break; + } + } + if (bApplyFailed) break; + } + } + } + } + if (bApplyFailed) + { + const bool bRolledBack = GEditor && GEditor->UndoTransaction(); + if (!bRolledBack) + { + return MCPError(ApplyError + TEXT("; the editor transaction could not be rolled back")); + } + return MCPError(ApplyError); + } + Session.Sequence->MarkPackageDirty(); + if (!UEditorAssetLibrary::SaveLoadedAsset(Session.Sequence, false)) + { + const bool bRolledBack = GEditor && GEditor->UndoTransaction(); + return MCPError(bRolledBack + ? TEXT("Control Rig edits could not be saved and were rolled back") + : TEXT("Control Rig edits could not be saved and the editor transaction could not be rolled back")); + } + + auto Result = MCPSuccess(); + Result->SetStringField(TEXT("sequencePath"), Session.Sequence->GetPathName()); + Result->SetStringField(TEXT("bindingTag"), Session.BindingTag); + Result->SetNumberField(TEXT("appliedOperationCount"), Operations->Num()); + Result->SetNumberField(TEXT("keyedControlWriteCount"), Prepared.Num()); + int32 KeyedSamples = 0; + TArray> Applied; + for (const FControlRigPreparedWrite& Write : Prepared) + { + KeyedSamples += Write.Frames.Num(); + auto Object = MakeShared(); + Object->SetStringField(TEXT("op"), Write.Op); + Object->SetStringField(TEXT("control"), Write.Control.ToString()); + Object->SetNumberField(TEXT("keyedFrameCount"), Write.Frames.Num()); + Applied.Add(MakeShared(Object)); + } + Result->SetNumberField(TEXT("keyedSampleCount"), KeyedSamples); + Result->SetArrayField(TEXT("applied"), Applied); + TArray> ContactResults; + for (const FControlRigPreparedContactQA& Contact : PreparedContacts) + { + auto Object = Contact.bHasDrivenReference + ? MakeShared() + : ControlRigSequencerContactMetricsJson(Contact.Metrics, true, Contact.bCheckRotation); + Object->SetNumberField(TEXT("operationIndex"), Contact.OperationIndex); + Object->SetStringField(TEXT("control"), Contact.Control.ToString()); + if (Contact.bHasDrivenReference) + { + Object->SetStringField(TEXT("drivenReference"), Contact.DrivenReference.ToString()); + Object->SetStringField(TEXT("verification"), TEXT("bake_and_analyze_required")); + if (Contact.bUsedFkRotationChain) + { + Object->SetStringField(TEXT("solver"), TEXT("fk_rotation_chain")); + Object->SetObjectField( + TEXT("preBakePrediction"), + ControlRigSequencerContactMetricsJson(Contact.Metrics, true, Contact.bCheckRotation)); + } + } + Object->SetNumberField(TEXT("frameCount"), Contact.Frames.Num()); + Object->SetNumberField(TEXT("fullWeightFrameCount"), Contact.FullWeightFrameCount); + Object->SetNumberField(TEXT("positionToleranceCm"), Contact.PositionToleranceCm); + if (Contact.bCheckRotation) + { + Object->SetNumberField(TEXT("rotationToleranceDegrees"), Contact.RotationToleranceDegrees); + } + TArray> StabilizerResults; + for (const FControlRigContactStabilizerQA& Stabilizer : Contact.Stabilizers) + { + const bool bCheckPosition = + ControlRigSequencerControlHasTranslation(Stabilizer.ControlType); + const bool bCheckRotation = + ControlRigSequencerControlHasRotation(Stabilizer.ControlType); + auto StabilizerObject = ControlRigSequencerContactMetricsJson( + Stabilizer.Metrics, bCheckPosition, bCheckRotation); + StabilizerObject->SetStringField(TEXT("control"), Stabilizer.Control.ToString()); + StabilizerResults.Add(MakeShared(StabilizerObject)); + } + Object->SetArrayField(TEXT("stabilizers"), StabilizerResults); + Object->SetBoolField(TEXT("keyReadbackPassed"), true); + if (!Contact.bHasDrivenReference) Object->SetBoolField(TEXT("passed"), true); + ContactResults.Add(MakeShared(Object)); + } + Result->SetArrayField(TEXT("contactQa"), ContactResults); + MCPSetUpdated(Result); + return MCPResult(Result); +#endif +} + +TSharedPtr FAnimationHandlers::BakeControlRigEdit(const TSharedPtr& Params) +{ +#if !UE_MCP_HAS_5_8_API + return ControlRigSequencerUnsupported(); +#else + FString SequencePath; + FString OutputAssetPath; + if (auto Error = RequireString(Params, TEXT("sequencePath"), SequencePath)) return Error; + if (auto Error = RequireString(Params, TEXT("outputAssetPath"), OutputAssetPath)) return Error; + + FString PackagePath; + FString AssetName; + FString Error; + if (!ControlRigSequencerSplitAssetPath(OutputAssetPath, PackagePath, AssetName, Error)) return MCPError(Error); + const FString OnConflict = OptionalString(Params, TEXT("onConflict"), TEXT("error")).ToLower(); + if (OnConflict != TEXT("skip") && OnConflict != TEXT("error")) + return MCPError(TEXT("'onConflict' must be 'skip' or 'error'; bake never overwrites an AnimSequence")); + if (UObject* Existing = UEditorAssetLibrary::LoadAsset(OutputAssetPath)) + { + if (OnConflict == TEXT("error")) return MCPError(FString::Printf(TEXT("Output asset already exists: %s"), *OutputAssetPath)); + if (!Existing->IsA()) return MCPError(FString::Printf(TEXT("Existing output is not an AnimSequence: %s"), *OutputAssetPath)); + auto Result = MCPSuccess(); + MCPSetExisted(Result); + Result->SetStringField(TEXT("outputAssetPath"), Existing->GetPathName()); + return MCPResult(Result); + } + + ULevelSequence* Sequence = Cast(UEditorAssetLibrary::LoadAsset(SequencePath)); + if (!Sequence) return MCPError(FString::Printf(TEXT("LevelSequence not found: %s"), *SequencePath)); + FControlRigSequenceFocusGuard Focus(Sequence); + if (!Focus.IsReady()) return MCPError(TEXT("Could not focus the LevelSequence in Sequencer")); + FControlRigSequenceSession Session; + if (!ControlRigSequencerResolveSession(Params, Session, Error)) return MCPError(Error); + + FFrameRate FrameRate; + if (!ControlRigSequencerReadRate(Params, TEXT("frameRate"), Session.MovieScene->GetDisplayRate(), FrameRate, Error)) + return MCPError(Error); + const bool bReduceKeys = OptionalBool(Params, TEXT("reduceKeys"), false); + const double Tolerance = OptionalNumber(Params, TEXT("tolerance"), 0.001); + if (!FMath::IsFinite(Tolerance) || Tolerance < 0.0) + return MCPError(TEXT("'tolerance' must be a finite non-negative number")); + if (bReduceKeys) + { + return MCPError(TEXT("'reduceKeys' is not supported by AnimSequence export in this vertical slice; bake with reduceKeys=false")); + } + const bool bCreateLink = OptionalBool(Params, TEXT("createLink"), false); + if (bCreateLink) + { + return MCPError(TEXT("'createLink' is not supported because Unreal mutates both linked assets; bake with createLink=false")); + } + + auto Created = MCPCreateAssetIdempotentNewObject(AssetName, PackagePath, TEXT("error"), TEXT("AnimSequence")); + if (Created.EarlyReturn) return Created.EarlyReturn; + UAnimSequence* Output = Created.Asset; + UAnimSeqExportOption* ExportOptions = NewObject(); + ExportOptions->bUseCustomFrameRate = true; + ExportOptions->CustomFrameRate = FrameRate; + ExportOptions->bExportTransforms = true; + ExportOptions->bExportMorphTargets = true; + ExportOptions->bExportAttributeCurves = true; + ExportOptions->bExportMaterialCurves = true; + + const bool bExported = UControlRigSequencerEditorLibrary::ExportAnimSequenceFromSequencer( + Output, ExportOptions, FMovieSceneBindingProxy(Session.BindingGuid, Session.Sequence), false); + if (!bExported) + { + UEditorAssetLibrary::DeleteAsset(PackagePath + TEXT("/") + AssetName); + return MCPError(TEXT("Unreal failed to export the Control Rig edit to an AnimSequence")); + } + Output->MarkPackageDirty(); + if (!UEditorAssetLibrary::SaveLoadedAsset(Output, false)) + { + UEditorAssetLibrary::DeleteAsset(PackagePath + TEXT("/") + AssetName); + return MCPError(TEXT("AnimSequence export completed in memory but the output asset could not be saved")); + } + + auto Result = MCPSuccess(); + MCPSetCreated(Result); + Result->SetStringField(TEXT("sequencePath"), Session.Sequence->GetPathName()); + Result->SetStringField(TEXT("bindingTag"), Session.BindingTag); + Result->SetStringField(TEXT("outputAssetPath"), Output->GetPathName()); + if (Output->GetSkeleton()) Result->SetStringField(TEXT("skeletonPath"), Output->GetSkeleton()->GetPathName()); + Result->SetObjectField(TEXT("frameRate"), ControlRigSequencerRateJson(FrameRate)); + Result->SetNumberField(TEXT("sampledKeyCount"), Output->GetNumberOfSampledKeys()); + Result->SetNumberField(TEXT("durationSeconds"), Output->GetPlayLength()); + Result->SetBoolField(TEXT("createdLink"), false); + Result->SetBoolField(TEXT("sourceAnimationModified"), false); + MCPSetDeleteAssetRollback(Result, Output->GetPathName()); + return MCPResult(Result); +#endif +} diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp new file mode 100644 index 00000000..0f33f8bc --- /dev/null +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp @@ -0,0 +1,961 @@ +#include "AnimationHandlers.h" +#include "HandlerUtils.h" + +#if UE_MCP_HAS_5_8_API +#include "Editor.h" +#include "ScopedTransaction.h" +#include "Engine/SkeletalMesh.h" +#include "Rig/IKRigDefinition.h" +#include "Rig/IKRigSkeleton.h" +#include "RetargetEditor/IKRetargeterController.h" +#include "RetargetEditor/IKRetargeterPoseGenerator.h" +#include "Retargeter/IKRetargetChainMapping.h" +#include "Retargeter/IKRetargetOps.h" +#include "Retargeter/IKRetargetProcessor.h" +#include "Retargeter/IKRetargeter.h" +#include "Retargeter/RetargetOps/CurveRemapOp.h" +#include "Retargeter/RetargetOps/FKChainsOp.h" +#include "Retargeter/RetargetOps/PelvisMotionOp.h" +#include "Retargeter/RetargetOps/RootMotionGeneratorOp.h" +#include "Retargeter/RetargetOps/RunIKRigOp.h" + +namespace +{ + constexpr int32 MaxRetargeterItems = 10000; + + struct FPreparedChainMapping + { + FName TargetChain; + FName SourceChain; + }; + + struct FPreparedPoseRotation + { + FName Bone; + FQuat Rotation; + }; + + struct FPreparedRetargetPose + { + ERetargetSourceOrTarget Side = ERetargetSourceOrTarget::Target; + FName Name; + bool bCreate = false; + bool bReset = false; + bool bHasAutoAlign = false; + bool bAutoAlignAll = false; + ERetargetAutoAlignMethod AutoAlignMethod = ERetargetAutoAlignMethod::ChainToChain; + TArray AutoAlignBones; + TArray Rotations; + TOptional RootOffsetZ; + TOptional SnapBone; + }; + + // Processor initialization temporarily points serialized ops/settings at its + // live editor copies. Preserve any viewport-owned pointers so a validation-only + // processor cannot leave the open retargeter editor with dangling references. + class FScopedRetargetEditorInstanceRestore + { + struct FState + { + FIKRetargetOpBase* Op = nullptr; + FIKRetargetOpBase* OpEditorInstance = nullptr; + FIKRetargetOpSettingsBase* Settings = nullptr; + FIKRetargetOpSettingsBase* SettingsEditorInstance = nullptr; + }; + + public: + explicit FScopedRetargetEditorInstanceRestore(UIKRetargeter* Retargeter) + { + if (!Retargeter) return; + States.Reserve(Retargeter->GetRetargetOps().Num()); + for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps()) + { + FIKRetargetOpBase* Op = const_cast(OpStruct.GetPtr()); + if (!Op) continue; + FIKRetargetOpSettingsBase* Settings = Op->GetSettings(); + States.Add({ + Op, + Op->EditorInstance, + Settings, + Settings ? Settings->EditorInstance : nullptr}); + } + } + + ~FScopedRetargetEditorInstanceRestore() + { + for (const FState& State : States) + { + State.Op->EditorInstance = State.OpEditorInstance; + if (State.Settings) State.Settings->EditorInstance = State.SettingsEditorInstance; + } + } + + private: + TArray States; + }; + + bool ReadOptionalString( + const TSharedPtr& Object, + const TCHAR* Field, + bool& bOutPresent, + FString& OutValue, + FString& OutError) + { + bOutPresent = Object->HasField(Field); + if (!bOutPresent) + { + OutValue.Reset(); + return true; + } + if (!Object->TryGetStringField(Field, OutValue) || OutValue.IsEmpty()) + { + OutError = FString::Printf(TEXT("'%s' must be a non-empty string"), Field); + return false; + } + return true; + } + + bool ReadOptionalBool( + const TSharedPtr& Object, + const TCHAR* Field, + const bool DefaultValue, + bool& OutValue, + FString& OutError) + { + OutValue = DefaultValue; + if (!Object->HasField(Field)) return true; + if (!Object->TryGetBoolField(Field, OutValue)) + { + OutError = FString::Printf(TEXT("'%s' must be a boolean"), Field); + return false; + } + return true; + } + + bool ReadNormalizedQuaternion( + const TSharedPtr& Object, + FQuat& OutRotation, + FString& OutError) + { + const TSharedPtr* Quaternion = nullptr; + if (!Object->TryGetObjectField(TEXT("rotationQuaternion"), Quaternion) + || !Quaternion || !Quaternion->IsValid()) + { + OutError = TEXT("'rotationQuaternion' must be an object with finite x, y, z and w numbers"); + return false; + } + + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + double W = 0.0; + if (!(*Quaternion)->TryGetNumberField(TEXT("x"), X) + || !(*Quaternion)->TryGetNumberField(TEXT("y"), Y) + || !(*Quaternion)->TryGetNumberField(TEXT("z"), Z) + || !(*Quaternion)->TryGetNumberField(TEXT("w"), W) + || !FMath::IsFinite(X) || !FMath::IsFinite(Y) + || !FMath::IsFinite(Z) || !FMath::IsFinite(W)) + { + OutError = TEXT("'rotationQuaternion' must contain finite x, y, z and w numbers"); + return false; + } + + OutRotation = FQuat(X, Y, Z, W); + const double Length = OutRotation.Size(); + constexpr double NormalizedTolerance = 1e-3; + if (Length <= UE_SMALL_NUMBER) + { + OutError = TEXT("'rotationQuaternion' must have non-zero length"); + return false; + } + if (FMath::Abs(Length - 1.0) > NormalizedTolerance) + { + OutError = FString::Printf( + TEXT("'rotationQuaternion' must be normalized within %.4f (length was %.8f)"), + NormalizedTolerance, Length); + return false; + } + OutRotation.Normalize(); + return true; + } + + bool ParseSide(const FString& Value, ERetargetSourceOrTarget& OutSide) + { + if (Value.Equals(TEXT("source"), ESearchCase::IgnoreCase)) + { + OutSide = ERetargetSourceOrTarget::Source; + return true; + } + if (Value.Equals(TEXT("target"), ESearchCase::IgnoreCase)) + { + OutSide = ERetargetSourceOrTarget::Target; + return true; + } + return false; + } + + bool ParseAutoMapMode(const FString& Value, EAutoMapChainType& OutMode) + { + if (Value.Equals(TEXT("exact"), ESearchCase::IgnoreCase)) + { + OutMode = EAutoMapChainType::Exact; + return true; + } + if (Value.Equals(TEXT("fuzzy"), ESearchCase::IgnoreCase)) + { + OutMode = EAutoMapChainType::Fuzzy; + return true; + } + if (Value.Equals(TEXT("clear"), ESearchCase::IgnoreCase)) + { + OutMode = EAutoMapChainType::Clear; + return true; + } + return false; + } + + bool ParseAutoAlignMethod(const FString& Value, ERetargetAutoAlignMethod& OutMethod) + { + if (Value.Equals(TEXT("chain_to_chain"), ESearchCase::IgnoreCase)) + { + OutMethod = ERetargetAutoAlignMethod::ChainToChain; + return true; + } + if (Value.Equals(TEXT("mesh_to_mesh"), ESearchCase::IgnoreCase)) + { + OutMethod = ERetargetAutoAlignMethod::MeshToMesh; + return true; + } + if (Value.Equals(TEXT("local_axes"), ESearchCase::IgnoreCase)) + { + OutMethod = ERetargetAutoAlignMethod::LocalRotationAxes; + return true; + } + if (Value.Equals(TEXT("global_axes"), ESearchCase::IgnoreCase)) + { + OutMethod = ERetargetAutoAlignMethod::GlobalRotationAxes; + return true; + } + return false; + } + + bool HasRetargetOpType(const UIKRetargeter* Retargeter, const UScriptStruct* Type) + { + if (!Retargeter || !Type) return false; + for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps()) + { + const UScriptStruct* ActualType = OpStruct.GetScriptStruct(); + if (ActualType && ActualType->IsChildOf(Type)) return true; + } + return false; + } + + bool HasAllDefaultOps(const UIKRetargeter* Retargeter) + { + return HasRetargetOpType(Retargeter, FIKRetargetPelvisMotionOp::StaticStruct()) + && HasRetargetOpType(Retargeter, FIKRetargetFKChainsOp::StaticStruct()) + && HasRetargetOpType(Retargeter, FIKRetargetRunIKRigOp::StaticStruct()) + && HasRetargetOpType(Retargeter, FIKRetargetRootMotionOp::StaticStruct()) + && HasRetargetOpType(Retargeter, FIKRetargetCurveRemapOp::StaticStruct()); + } + + TSet GetChainNames(const UIKRigDefinition* Rig) + { + TSet Names; + if (!Rig) return Names; + for (const FBoneChain& Chain : Rig->GetRetargetChains()) Names.Add(Chain.ChainName); + return Names; + } + + bool HasBone(const UIKRigDefinition* Rig, const USkeletalMesh* Mesh, const FName Bone) + { + if (Mesh && Mesh->GetRefSkeleton().FindBoneIndex(Bone) != INDEX_NONE) return true; + return Rig && Rig->GetSkeleton().GetBoneIndexFromName(Bone) != INDEX_NONE; + } + + int32 CountMappedChains(const UIKRetargeter* Retargeter) + { + int32 Count = 0; + if (!Retargeter) return Count; + for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps()) + { + const FIKRetargetOpBase* Op = OpStruct.GetPtr(); + const FRetargetChainMapping* Mapping = Op ? Op->GetChainMapping() : nullptr; + if (!Mapping) continue; + for (const FRetargetChainPair& Pair : Mapping->GetChainPairs()) + { + if (!Pair.SourceChainName.IsNone()) ++Count; + } + } + return Count; + } + + TSharedPtr QuaternionJson(const FQuat& Rotation) + { + auto Result = MakeShared(); + Result->SetNumberField(TEXT("x"), Rotation.X); + Result->SetNumberField(TEXT("y"), Rotation.Y); + Result->SetNumberField(TEXT("z"), Rotation.Z); + Result->SetNumberField(TEXT("w"), Rotation.W); + return Result; + } + + TArray> TextArrayJson(const TArray& Values) + { + TArray> Result; + Result.Reserve(Values.Num()); + for (const FText& Value : Values) + { + Result.Add(MakeShared(Value.ToString())); + } + return Result; + } + + TArray> BuildOpsJson(const UIKRetargeter* Retargeter) + { + TArray> Result; + if (!Retargeter) return Result; + const TArray& Ops = Retargeter->GetRetargetOps(); + Result.Reserve(Ops.Num()); + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FIKRetargetOpBase* Op = Ops[Index].GetPtr(); + if (!Op) continue; + auto Entry = MakeShared(); + Entry->SetNumberField(TEXT("index"), Index); + Entry->SetStringField(TEXT("name"), Op->GetName().ToString()); + Entry->SetStringField(TEXT("parent"), Op->GetParentOpName().ToString()); + Entry->SetBoolField(TEXT("enabled"), Op->IsEnabled()); + Entry->SetStringField(TEXT("type"), Op->GetType() ? Op->GetType()->GetPathName() : TEXT("")); + Entry->SetBoolField(TEXT("hasChainMapping"), Op->GetChainMapping() != nullptr); + if (const UIKRigDefinition* TargetRig = Op->GetCustomTargetIKRig()) + { + Entry->SetStringField(TEXT("targetRig"), TargetRig->GetPathName()); + } + Result.Add(MakeShared(Entry)); + } + return Result; + } + + TArray> BuildMappingsJson(const UIKRetargeter* Retargeter) + { + TArray> Result; + if (!Retargeter) return Result; + const TArray& Ops = Retargeter->GetRetargetOps(); + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FIKRetargetOpBase* Op = Ops[Index].GetPtr(); + const FRetargetChainMapping* Mapping = Op ? Op->GetChainMapping() : nullptr; + if (!Mapping) continue; + auto MappingObject = MakeShared(); + MappingObject->SetNumberField(TEXT("opIndex"), Index); + MappingObject->SetStringField(TEXT("opName"), Op->GetName().ToString()); + TArray> Chains; + for (const FRetargetChainPair& Pair : Mapping->GetChainPairs()) + { + auto Chain = MakeShared(); + Chain->SetStringField(TEXT("targetChain"), Pair.TargetChainName.ToString()); + Chain->SetStringField(TEXT("sourceChain"), Pair.SourceChainName.ToString()); + Chains.Add(MakeShared(Chain)); + } + MappingObject->SetArrayField(TEXT("chains"), Chains); + Result.Add(MakeShared(MappingObject)); + } + return Result; + } + + TSharedPtr BuildPoseJson( + UIKRetargeterController* Controller, + const ERetargetSourceOrTarget Side, + const FName PoseName, + const bool bAutoAlignResetPose) + { + auto Result = MakeShared(); + Result->SetStringField(TEXT("side"), Side == ERetargetSourceOrTarget::Source ? TEXT("source") : TEXT("target")); + Result->SetStringField(TEXT("name"), PoseName.ToString()); + Result->SetBoolField(TEXT("current"), Controller->GetCurrentRetargetPoseName(Side) == PoseName); + Result->SetBoolField(TEXT("autoAlignResetPose"), bAutoAlignResetPose); + FIKRetargetPose& Pose = Controller->GetRetargetPoses(Side).FindChecked(PoseName); + Result->SetNumberField(TEXT("rootOffsetZ"), Pose.GetRootTranslationDelta().Z); + + TArray BoneNames; + Pose.GetAllDeltaRotations().GetKeys(BoneNames); + BoneNames.Sort([](const FName A, const FName B) + { + return A.ToString() < B.ToString(); + }); + TArray> RotationOffsets; + RotationOffsets.Reserve(BoneNames.Num()); + for (const FName Bone : BoneNames) + { + auto Offset = MakeShared(); + Offset->SetStringField(TEXT("bone"), Bone.ToString()); + Offset->SetObjectField(TEXT("rotationQuaternion"), QuaternionJson(Pose.GetDeltaRotationForBone(Bone))); + RotationOffsets.Add(MakeShared(Offset)); + } + Result->SetArrayField(TEXT("rotationOffsets"), RotationOffsets); + return Result; + } +} +#endif + +TSharedPtr FAnimationHandlers::ConfigureIKRetargeter(const TSharedPtr& Params) +{ +#if !UE_MCP_HAS_5_8_API + auto Result = MakeShared(); + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("errorCode"), TEXT("unsupported_engine_version")); + Result->SetStringField(TEXT("error"), TEXT("configure_ik_retargeter requires Unreal Engine 5.8 or newer")); + return MCPResult(Result); +#else + FString RetargeterPath; + if (auto Error = RequireString(Params, TEXT("retargeterPath"), RetargeterPath)) return Error; + if (MCPIsProtectedAssetPath(RetargeterPath)) + { + return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RetargeterPath)); + } + UIKRetargeter* Retargeter = LoadAssetByPath(RetargeterPath); + if (!Retargeter) return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *RetargeterPath)); + UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter); + if (!Controller) return MCPError(TEXT("IKRetargeterController unavailable")); + + FString Error; + bool bHasSourceRig = false; + bool bHasTargetRig = false; + bool bHasSourcePreview = false; + bool bHasTargetPreview = false; + FString SourceRigPath; + FString TargetRigPath; + FString SourcePreviewPath; + FString TargetPreviewPath; + if (!ReadOptionalString(Params, TEXT("sourceRig"), bHasSourceRig, SourceRigPath, Error) + || !ReadOptionalString(Params, TEXT("targetRig"), bHasTargetRig, TargetRigPath, Error) + || !ReadOptionalString(Params, TEXT("sourcePreviewMesh"), bHasSourcePreview, SourcePreviewPath, Error) + || !ReadOptionalString(Params, TEXT("targetPreviewMesh"), bHasTargetPreview, TargetPreviewPath, Error)) + { + return MCPError(Error); + } + + bool bEnsureDefaultOps = true; + bool bForceRemap = false; + if (!ReadOptionalBool(Params, TEXT("ensureDefaultOps"), true, bEnsureDefaultOps, Error) + || !ReadOptionalBool(Params, TEXT("forceRemap"), false, bForceRemap, Error)) + { + return MCPError(Error); + } + if (bEnsureDefaultOps && Controller->GetNumRetargetOps() > 0 && !HasAllDefaultOps(Retargeter)) + { + return MCPError(TEXT("ensureDefaultOps cannot safely augment a partial retarget op stack; complete the stack in the editor or pass ensureDefaultOps=false")); + } + + UIKRigDefinition* SourceRig = bHasSourceRig + ? LoadAssetByPath(SourceRigPath) + : Retargeter->GetIKRigWriteable(ERetargetSourceOrTarget::Source); + UIKRigDefinition* TargetRig = bHasTargetRig + ? LoadAssetByPath(TargetRigPath) + : Retargeter->GetIKRigWriteable(ERetargetSourceOrTarget::Target); + if (bHasSourceRig && !SourceRig) return MCPError(FString::Printf(TEXT("Source IKRig not found: %s"), *SourceRigPath)); + if (bHasTargetRig && !TargetRig) return MCPError(FString::Printf(TEXT("Target IKRig not found: %s"), *TargetRigPath)); + + USkeletalMesh* SourcePreview = bHasSourcePreview + ? LoadAssetByPath(SourcePreviewPath) + : (bHasSourceRig ? SourceRig->GetPreviewMesh() : Controller->GetPreviewMesh(ERetargetSourceOrTarget::Source)); + USkeletalMesh* TargetPreview = bHasTargetPreview + ? LoadAssetByPath(TargetPreviewPath) + : (bHasTargetRig ? TargetRig->GetPreviewMesh() : Controller->GetPreviewMesh(ERetargetSourceOrTarget::Target)); + if (bHasSourcePreview && !SourcePreview) return MCPError(FString::Printf(TEXT("Source SkeletalMesh not found: %s"), *SourcePreviewPath)); + if (bHasTargetPreview && !TargetPreview) return MCPError(FString::Printf(TEXT("Target SkeletalMesh not found: %s"), *TargetPreviewPath)); + + bool bHasAutoMap = false; + FString AutoMapModeString; + EAutoMapChainType AutoMapMode = EAutoMapChainType::Exact; + if (!ReadOptionalString(Params, TEXT("autoMapMode"), bHasAutoMap, AutoMapModeString, Error)) return MCPError(Error); + if (bHasAutoMap && !ParseAutoMapMode(AutoMapModeString, AutoMapMode)) + { + return MCPError(TEXT("'autoMapMode' must be 'exact', 'fuzzy' or 'clear'")); + } + + TArray PreparedMappings; + TSet RequestedTargetChains; + const TArray>* MappingValues = nullptr; + if (Params->HasField(TEXT("chainMappings"))) + { + if (!Params->TryGetArrayField(TEXT("chainMappings"), MappingValues) || !MappingValues) + return MCPError(TEXT("'chainMappings' must be an array")); + if (MappingValues->Num() > MaxRetargeterItems) + return MCPError(FString::Printf(TEXT("'chainMappings' exceeds the %d item limit"), MaxRetargeterItems)); + if (!SourceRig || !TargetRig) + return MCPError(TEXT("'chainMappings' requires both source and target IK Rigs")); + const TSet SourceChains = GetChainNames(SourceRig); + const TSet TargetChains = GetChainNames(TargetRig); + for (int32 Index = 0; Index < MappingValues->Num(); ++Index) + { + const TSharedPtr& MappingValue = (*MappingValues)[Index]; + const TSharedPtr Mapping = MappingValue.IsValid() + && MappingValue->Type == EJson::Object + ? (*MappingValues)[Index]->AsObject() : nullptr; + FString TargetName; + if (!Mapping || !Mapping->TryGetStringField(TEXT("targetChain"), TargetName) || TargetName.IsEmpty()) + return MCPError(FString::Printf(TEXT("chainMappings[%d].targetChain must be a non-empty string"), Index)); + const FName TargetChain(*TargetName); + if (TargetChain.IsNone()) + return MCPError(FString::Printf(TEXT("chainMappings[%d].targetChain cannot be 'None'"), Index)); + if (!TargetChains.Contains(TargetChain)) + return MCPError(FString::Printf(TEXT("Target chain not found: %s"), *TargetName)); + if (RequestedTargetChains.Contains(TargetChain)) + return MCPError(FString::Printf(TEXT("Duplicate target chain mapping: %s"), *TargetName)); + RequestedTargetChains.Add(TargetChain); + + FName SourceChain = NAME_None; + const TSharedPtr SourceValue = Mapping->TryGetField(TEXT("sourceChain")); + if (SourceValue.IsValid() && SourceValue->Type != EJson::Null) + { + FString SourceName; + if (!SourceValue->TryGetString(SourceName) || SourceName.IsEmpty()) + return MCPError(FString::Printf(TEXT("chainMappings[%d].sourceChain must be a non-empty string or null"), Index)); + SourceChain = FName(*SourceName); + if (SourceChain.IsNone()) + return MCPError(FString::Printf(TEXT("chainMappings[%d].sourceChain cannot be 'None'; omit it or use null to clear the mapping"), Index)); + if (!SourceChains.Contains(SourceChain)) + return MCPError(FString::Printf(TEXT("Source chain not found: %s"), *SourceName)); + } + PreparedMappings.Add({TargetChain, SourceChain}); + } + } + if ((bHasAutoMap || !PreparedMappings.IsEmpty()) && (!SourceRig || !TargetRig)) + return MCPError(TEXT("Chain mapping requires both source and target IK Rigs")); + + TOptional PreparedPose; + const TSharedPtr* PoseObjectPointer = nullptr; + if (Params->HasField(TEXT("pose"))) + { + if (!Params->TryGetObjectField(TEXT("pose"), PoseObjectPointer) + || !PoseObjectPointer || !PoseObjectPointer->IsValid()) + { + return MCPError(TEXT("'pose' must be an object")); + } + const TSharedPtr& PoseObject = *PoseObjectPointer; + FPreparedRetargetPose Pose; + FString SideString; + FString PoseName; + if (!PoseObject->TryGetStringField(TEXT("side"), SideString) || !ParseSide(SideString, Pose.Side)) + return MCPError(TEXT("pose.side must be 'source' or 'target'")); + if (!PoseObject->TryGetStringField(TEXT("name"), PoseName) || PoseName.IsEmpty()) + return MCPError(TEXT("pose.name must be a non-empty string")); + Pose.Name = FName(*PoseName); + if (Pose.Name.IsNone()) return MCPError(TEXT("pose.name cannot be 'None'")); + if (!ReadOptionalBool(PoseObject, TEXT("create"), false, Pose.bCreate, Error) + || !ReadOptionalBool(PoseObject, TEXT("reset"), false, Pose.bReset, Error)) + { + return MCPError(Error); + } + + UIKRigDefinition* PoseRig = Pose.Side == ERetargetSourceOrTarget::Source ? SourceRig : TargetRig; + USkeletalMesh* PoseMesh = Pose.Side == ERetargetSourceOrTarget::Source ? SourcePreview : TargetPreview; + if (!PoseRig) return MCPError(TEXT("Pose authoring requires an IK Rig on the selected side")); + const bool bPoseExists = Controller->GetRetargetPoses(Pose.Side).Contains(Pose.Name); + if (Pose.bCreate && bPoseExists) + return MCPError(FString::Printf(TEXT("Retarget pose already exists: %s"), *PoseName)); + if (!Pose.bCreate && !bPoseExists) + return MCPError(FString::Printf(TEXT("Retarget pose not found: %s"), *PoseName)); + + FString AutoAlignString; + if (PoseObject->HasField(TEXT("autoAlign"))) + { + if (!PoseObject->TryGetStringField(TEXT("autoAlign"), AutoAlignString) + || !ParseAutoAlignMethod(AutoAlignString, Pose.AutoAlignMethod)) + { + return MCPError(TEXT("pose.autoAlign must be 'chain_to_chain', 'mesh_to_mesh', 'local_axes' or 'global_axes'")); + } + Pose.bHasAutoAlign = true; + if (!SourcePreview || !TargetPreview) + return MCPError(TEXT("pose.autoAlign requires both source and target preview meshes")); + } + + const TArray>* BoneValues = nullptr; + if (PoseObject->HasField(TEXT("bones"))) + { + if (!Pose.bHasAutoAlign) return MCPError(TEXT("pose.bones requires pose.autoAlign")); + if (!PoseObject->TryGetArrayField(TEXT("bones"), BoneValues) || !BoneValues) + return MCPError(TEXT("pose.bones must be an array of bone names")); + if (BoneValues->Num() > MaxRetargeterItems) + return MCPError(FString::Printf(TEXT("pose.bones exceeds the %d item limit"), MaxRetargeterItems)); + TSet SeenBones; + for (int32 Index = 0; Index < BoneValues->Num(); ++Index) + { + FString BoneString; + if (!(*BoneValues)[Index].IsValid() + || !(*BoneValues)[Index]->TryGetString(BoneString) || BoneString.IsEmpty()) + return MCPError(FString::Printf(TEXT("pose.bones[%d] must be a non-empty string"), Index)); + const FName Bone(*BoneString); + if (Bone.IsNone()) return MCPError(FString::Printf(TEXT("pose.bones[%d] cannot be 'None'"), Index)); + if (SeenBones.Contains(Bone)) return MCPError(FString::Printf(TEXT("Duplicate pose auto-align bone: %s"), *BoneString)); + if (!HasBone(PoseRig, PoseMesh, Bone)) return MCPError(FString::Printf(TEXT("Pose bone not found: %s"), *BoneString)); + SeenBones.Add(Bone); + Pose.AutoAlignBones.Add(Bone); + } + } + Pose.bAutoAlignAll = Pose.bHasAutoAlign && Pose.AutoAlignBones.IsEmpty(); + if (Pose.bAutoAlignAll && !Pose.bCreate && !Pose.bReset) + { + return MCPError(TEXT("Auto-aligning all bones resets the entire existing pose; pass pose.reset=true to acknowledge that replacement")); + } + + const TArray>* RotationValues = nullptr; + if (PoseObject->HasField(TEXT("rotationOffsets"))) + { + if (!PoseObject->TryGetArrayField(TEXT("rotationOffsets"), RotationValues) || !RotationValues) + return MCPError(TEXT("pose.rotationOffsets must be an array")); + if (RotationValues->Num() > MaxRetargeterItems) + return MCPError(FString::Printf(TEXT("pose.rotationOffsets exceeds the %d item limit"), MaxRetargeterItems)); + TSet SeenBones; + for (int32 Index = 0; Index < RotationValues->Num(); ++Index) + { + const TSharedPtr& RotationValue = (*RotationValues)[Index]; + const TSharedPtr Offset = RotationValue.IsValid() + && RotationValue->Type == EJson::Object + ? (*RotationValues)[Index]->AsObject() : nullptr; + FString BoneString; + if (!Offset || !Offset->TryGetStringField(TEXT("bone"), BoneString) || BoneString.IsEmpty()) + return MCPError(FString::Printf(TEXT("pose.rotationOffsets[%d].bone must be a non-empty string"), Index)); + const FName Bone(*BoneString); + if (Bone.IsNone()) return MCPError(FString::Printf(TEXT("pose.rotationOffsets[%d].bone cannot be 'None'"), Index)); + if (SeenBones.Contains(Bone)) return MCPError(FString::Printf(TEXT("Duplicate pose rotation bone: %s"), *BoneString)); + if (!HasBone(PoseRig, PoseMesh, Bone)) return MCPError(FString::Printf(TEXT("Pose bone not found: %s"), *BoneString)); + FQuat Rotation; + if (!ReadNormalizedQuaternion(Offset, Rotation, Error)) + return MCPError(FString::Printf(TEXT("pose.rotationOffsets[%d]: %s"), Index, *Error)); + SeenBones.Add(Bone); + Pose.Rotations.Add({Bone, Rotation}); + } + } + + if (PoseObject->HasField(TEXT("rootOffsetZ"))) + { + double RootOffsetZ = 0.0; + if (!PoseObject->TryGetNumberField(TEXT("rootOffsetZ"), RootOffsetZ) || !FMath::IsFinite(RootOffsetZ)) + return MCPError(TEXT("pose.rootOffsetZ must be a finite number")); + Pose.RootOffsetZ = RootOffsetZ; + } + if (PoseObject->HasField(TEXT("snapBoneToGround"))) + { + FString SnapBoneString; + if (!PoseObject->TryGetStringField(TEXT("snapBoneToGround"), SnapBoneString) || SnapBoneString.IsEmpty()) + return MCPError(TEXT("pose.snapBoneToGround must be a non-empty bone name")); + const FName SnapBone(*SnapBoneString); + if (SnapBone.IsNone()) return MCPError(TEXT("pose.snapBoneToGround cannot be 'None'")); + if (!SourcePreview || !TargetPreview) + return MCPError(TEXT("pose.snapBoneToGround requires both source and target preview meshes")); + if (!PoseMesh || PoseMesh->GetRefSkeleton().FindBoneIndex(SnapBone) == INDEX_NONE) + return MCPError(FString::Printf(TEXT("Snap bone is not present in the selected preview mesh: %s"), *SnapBoneString)); + Pose.SnapBone = SnapBone; + } + if (Pose.RootOffsetZ.IsSet() && Pose.SnapBone.IsSet()) + return MCPError(TEXT("pose.rootOffsetZ and pose.snapBoneToGround are mutually exclusive because snapping changes the root offset")); + PreparedPose = MoveTemp(Pose); + } + + bool bMutationFailed = false; + FString MutationError; + bool bAddedDefaultOps = false; + bool bValidationRan = false; + bool bValidationInitialized = false; + TArray ValidationErrors; + TArray ValidationWarnings; + { + const FScopedTransaction Transaction(NSLOCTEXT("UE_MCP", "ConfigureIKRetargeter", "Configure IK Retargeter")); + Retargeter->Modify(); + + if (bEnsureDefaultOps && !HasAllDefaultOps(Retargeter)) + { + Controller->AddDefaultOps(); + bAddedDefaultOps = true; + if (!HasAllDefaultOps(Retargeter)) + { + bMutationFailed = true; + MutationError = TEXT("Unreal failed to install the complete default retarget op stack"); + } + } + + if (!bMutationFailed && bHasSourceRig) Controller->SetIKRig(ERetargetSourceOrTarget::Source, SourceRig); + if (!bMutationFailed && bHasTargetRig) Controller->SetIKRig(ERetargetSourceOrTarget::Target, TargetRig); + if (!bMutationFailed && (bHasSourceRig || bAddedDefaultOps) && SourceRig) + Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Source, SourceRig); + if (!bMutationFailed && (bHasTargetRig || bAddedDefaultOps) && TargetRig) + Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Target, TargetRig); + if (!bMutationFailed && bHasSourcePreview) Controller->SetPreviewMesh(ERetargetSourceOrTarget::Source, SourcePreview); + if (!bMutationFailed && bHasTargetPreview) Controller->SetPreviewMesh(ERetargetSourceOrTarget::Target, TargetPreview); + + if (!bMutationFailed && bHasAutoMap) Controller->AutoMapChains(AutoMapMode, bForceRemap); + if (!bMutationFailed) + { + for (const FPreparedChainMapping& Mapping : PreparedMappings) + { + if (!Controller->SetSourceChain(Mapping.SourceChain, Mapping.TargetChain)) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("No retarget op accepted target chain '%s'"), *Mapping.TargetChain.ToString()); + break; + } + } + } + if (!bMutationFailed && PreparedPose.IsSet() + && PreparedPose.GetValue().bHasAutoAlign && !PreparedPose.GetValue().bAutoAlignAll) + { + const FPreparedRetargetPose& Pose = PreparedPose.GetValue(); + FIKRetargetProcessor MappingProcessor; + FScopedRetargetEditorInstanceRestore RestoreEditorInstances(Retargeter); + FRetargetInitParameters InitParameters; + InitParameters.SourceSkeletalMesh = SourcePreview; + InitParameters.TargetSkeletalMesh = TargetPreview; + InitParameters.RetargeterAsset = Retargeter; + InitParameters.bSuppressWarnings = true; + MappingProcessor.Initialize(InitParameters); + const TArray& MappingErrors = MappingProcessor.Log.GetErrors(); + if (!MappingProcessor.IsInitialized() || !MappingErrors.IsEmpty()) + { + bMutationFailed = true; + MutationError = !MappingErrors.IsEmpty() + ? MappingErrors[0].ToString() + : TEXT("Retarget pose auto-alignment could not initialize the retarget processor"); + } + else + { + for (const FName Bone : Pose.AutoAlignBones) + { + if (!MappingProcessor.IsBoneMapped(Bone, Pose.Side)) + { + bMutationFailed = true; + MutationError = FString::Printf( + TEXT("Retarget pose auto-align bone is not mapped: %s"), *Bone.ToString()); + break; + } + } + } + } + + if (!bMutationFailed && PreparedPose.IsSet()) + { + const FPreparedRetargetPose& Pose = PreparedPose.GetValue(); + if (Pose.bCreate) + { + const FName CreatedPose = Controller->CreateRetargetPose(Pose.Name, Pose.Side); + if (CreatedPose != Pose.Name) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("Unreal created retarget pose '%s' instead of '%s'"), *CreatedPose.ToString(), *Pose.Name.ToString()); + } + } + else if (!Controller->SetCurrentRetargetPose(Pose.Name, Pose.Side)) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("Unreal failed to select retarget pose '%s'"), *Pose.Name.ToString()); + } + + if (!bMutationFailed && Pose.bReset) + Controller->ResetRetargetPose(Pose.Name, TArray(), Pose.Side); + if (!bMutationFailed && Pose.bHasAutoAlign) + { + if (CountMappedChains(Retargeter) == 0) + { + bMutationFailed = true; + MutationError = TEXT("Retarget pose auto-alignment requires at least one mapped chain"); + } + else if (Pose.bAutoAlignAll) + { + Controller->AutoAlignAllBones(Pose.Side, Pose.AutoAlignMethod); + } + else + { + Controller->AutoAlignBones(Pose.AutoAlignBones, Pose.AutoAlignMethod, Pose.Side); + } + } + if (!bMutationFailed) + { + for (const FPreparedPoseRotation& Rotation : Pose.Rotations) + Controller->SetRotationOffsetForRetargetPoseBone(Rotation.Bone, Rotation.Rotation, Pose.Side); + if (Pose.RootOffsetZ.IsSet()) + { + const double CurrentZ = Controller->GetRootOffsetInRetargetPose(Pose.Side).Z; + Controller->SetRootOffsetInRetargetPose(FVector(0.0, 0.0, Pose.RootOffsetZ.GetValue() - CurrentZ), Pose.Side); + } + if (Pose.SnapBone.IsSet()) Controller->SnapBoneToGround(Pose.SnapBone.GetValue(), Pose.Side); + } + } + + if (!bMutationFailed) Controller->CleanAsset(); + + if (!bMutationFailed && bHasSourceRig + && Controller->GetIKRig(ERetargetSourceOrTarget::Source) != SourceRig) + { + bMutationFailed = true; + MutationError = TEXT("Source IK Rig assignment did not survive native readback"); + } + if (!bMutationFailed && bHasTargetRig + && Controller->GetIKRig(ERetargetSourceOrTarget::Target) != TargetRig) + { + bMutationFailed = true; + MutationError = TEXT("Target IK Rig assignment did not survive native readback"); + } + if (!bMutationFailed && bHasSourcePreview + && Controller->GetPreviewMesh(ERetargetSourceOrTarget::Source) != SourcePreview) + { + bMutationFailed = true; + MutationError = TEXT("Source preview mesh assignment did not survive native readback"); + } + if (!bMutationFailed && bHasTargetPreview + && Controller->GetPreviewMesh(ERetargetSourceOrTarget::Target) != TargetPreview) + { + bMutationFailed = true; + MutationError = TEXT("Target preview mesh assignment did not survive native readback"); + } + + if (!bMutationFailed) + { + for (const FPreparedChainMapping& Expected : PreparedMappings) + { + bool bFoundTarget = false; + for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps()) + { + const FIKRetargetOpBase* Op = OpStruct.GetPtr(); + const FRetargetChainMapping* Mapping = Op ? Op->GetChainMapping() : nullptr; + if (!Mapping || !Mapping->HasChain(Expected.TargetChain, ERetargetSourceOrTarget::Target)) continue; + bFoundTarget = true; + if (Mapping->GetChainMappedTo(Expected.TargetChain, ERetargetSourceOrTarget::Target) != Expected.SourceChain) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("Chain mapping readback failed for target '%s' on op '%s'"), + *Expected.TargetChain.ToString(), *Op->GetName().ToString()); + break; + } + } + if (bMutationFailed) break; + if (!bFoundTarget) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("No chain mapping contained target '%s' during readback"), *Expected.TargetChain.ToString()); + break; + } + } + } + + if (!bMutationFailed && bHasAutoMap && AutoMapMode == EAutoMapChainType::Clear) + { + for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps()) + { + const FIKRetargetOpBase* Op = OpStruct.GetPtr(); + const FRetargetChainMapping* Mapping = Op ? Op->GetChainMapping() : nullptr; + if (!Mapping) continue; + for (const FRetargetChainPair& Pair : Mapping->GetChainPairs()) + { + if (!RequestedTargetChains.Contains(Pair.TargetChainName) && !Pair.SourceChainName.IsNone()) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("Clear auto-map readback left target chain '%s' mapped"), *Pair.TargetChainName.ToString()); + break; + } + } + if (bMutationFailed) break; + } + } + + if (!bMutationFailed && PreparedPose.IsSet()) + { + const FPreparedRetargetPose& Pose = PreparedPose.GetValue(); + if (Controller->GetCurrentRetargetPoseName(Pose.Side) != Pose.Name) + { + bMutationFailed = true; + MutationError = TEXT("Retarget pose selection did not survive native readback"); + } + for (const FPreparedPoseRotation& Expected : Pose.Rotations) + { + const FQuat Actual = Controller->GetRotationOffsetForRetargetPoseBone(Expected.Bone, Pose.Side).GetNormalized(); + if (FMath::Abs(Actual | Expected.Rotation) < 1.0 - 1e-6) + { + bMutationFailed = true; + MutationError = FString::Printf(TEXT("Retarget pose rotation readback failed for bone '%s'"), *Expected.Bone.ToString()); + break; + } + } + if (!bMutationFailed && Pose.RootOffsetZ.IsSet() + && !FMath::IsNearlyEqual(Controller->GetRootOffsetInRetargetPose(Pose.Side).Z, Pose.RootOffsetZ.GetValue(), 1e-4)) + { + bMutationFailed = true; + MutationError = TEXT("Retarget pose root offset did not survive native readback"); + } + } + + USkeletalMesh* EffectiveSourceMesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Source); + USkeletalMesh* EffectiveTargetMesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Target); + if (!bMutationFailed && EffectiveSourceMesh && EffectiveTargetMesh) + { + bValidationRan = true; + FIKRetargetProcessor Processor; + FScopedRetargetEditorInstanceRestore RestoreEditorInstances(Retargeter); + FRetargetInitParameters InitParameters; + InitParameters.SourceSkeletalMesh = EffectiveSourceMesh; + InitParameters.TargetSkeletalMesh = EffectiveTargetMesh; + InitParameters.RetargeterAsset = Retargeter; + InitParameters.bSuppressWarnings = false; + Processor.Initialize(InitParameters); + bValidationInitialized = Processor.IsInitialized(); + ValidationErrors = Processor.Log.GetErrors(); + ValidationWarnings = Processor.Log.GetWarnings(); + if (!bValidationInitialized || !ValidationErrors.IsEmpty()) + { + bMutationFailed = true; + MutationError = !ValidationErrors.IsEmpty() + ? ValidationErrors[0].ToString() + : TEXT("IK Retargeter processor validation failed to initialize"); + } + } + } + + if (bMutationFailed) + { + const bool bRolledBack = GEditor && GEditor->UndoTransaction(); + return MCPError(bRolledBack + ? MutationError + : MutationError + TEXT("; the editor transaction could not be rolled back")); + } + if (!SaveAssetPackage(Retargeter)) + { + const bool bRolledBack = GEditor && GEditor->UndoTransaction(); + return MCPError(bRolledBack + ? TEXT("IK Retargeter configuration could not be saved and was rolled back") + : TEXT("IK Retargeter configuration could not be saved and the editor transaction could not be rolled back")); + } + + auto Result = MCPSuccess(); + MCPSetUpdated(Result); + Result->SetStringField(TEXT("retargeterPath"), Retargeter->GetPathName()); + if (const UIKRigDefinition* Rig = Controller->GetIKRig(ERetargetSourceOrTarget::Source)) + Result->SetStringField(TEXT("sourceRig"), Rig->GetPathName()); + if (const UIKRigDefinition* Rig = Controller->GetIKRig(ERetargetSourceOrTarget::Target)) + Result->SetStringField(TEXT("targetRig"), Rig->GetPathName()); + if (USkeletalMesh* Mesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Source)) + Result->SetStringField(TEXT("sourcePreviewMesh"), Mesh->GetPathName()); + if (USkeletalMesh* Mesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Target)) + Result->SetStringField(TEXT("targetPreviewMesh"), Mesh->GetPathName()); + Result->SetBoolField(TEXT("defaultOpsComplete"), HasAllDefaultOps(Retargeter)); + Result->SetBoolField(TEXT("defaultOpsAdded"), bAddedDefaultOps); + Result->SetArrayField(TEXT("ops"), BuildOpsJson(Retargeter)); + Result->SetArrayField(TEXT("mappings"), BuildMappingsJson(Retargeter)); + Result->SetNumberField(TEXT("mappedChainCountAcrossOps"), CountMappedChains(Retargeter)); + if (PreparedPose.IsSet()) + { + const FPreparedRetargetPose& Pose = PreparedPose.GetValue(); + Result->SetObjectField(TEXT("pose"), BuildPoseJson(Controller, Pose.Side, Pose.Name, Pose.bAutoAlignAll)); + } + + auto Validation = MakeShared(); + Validation->SetBoolField(TEXT("ran"), bValidationRan); + Validation->SetBoolField(TEXT("initialized"), bValidationInitialized); + Validation->SetArrayField(TEXT("errors"), TextArrayJson(ValidationErrors)); + Validation->SetArrayField(TEXT("warnings"), TextArrayJson(ValidationWarnings)); + if (!bValidationRan) + Validation->SetStringField(TEXT("reason"), TEXT("Both source and target preview meshes are required for processor validation")); + Result->SetObjectField(TEXT("validation"), Validation); + return MCPResult(Result); +#endif +} diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp new file mode 100644 index 00000000..f640b7f3 --- /dev/null +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp @@ -0,0 +1,900 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "AnimationHandlers.h" +#include "HandlerUtils.h" + +#if UE_MCP_HAS_5_8_API +#include "Editor.h" +#include "EditorAssetLibrary.h" +#include "Engine/SkeletalMesh.h" +#include "Rig/IKRigDataTypes.h" +#include "Rig/IKRigDefinition.h" +#include "Rig/IKRigSkeleton.h" +#include "Rig/Solvers/IKRigFullBodyIK.h" +#include "RigEditor/IKRigAutoCharacterizer.h" +#include "RigEditor/IKRigController.h" +#include "ScopedTransaction.h" +#include "StructUtils/InstancedStruct.h" +#endif + +namespace UE_MCP_IKRigAuthoring +{ +static TSharedPtr Error( + const FString& Code, + const FString& Message, + const TOptional& RollbackSucceeded = TOptional()) +{ + TSharedPtr Result = MakeShared(); + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("errorCode"), Code); + Result->SetStringField(TEXT("error"), Message); + Result->SetBoolField(TEXT("rollbackSafe"), !RollbackSucceeded.IsSet() || RollbackSucceeded.GetValue()); + if (RollbackSucceeded.IsSet()) + { + Result->SetBoolField(TEXT("rollbackAttempted"), true); + Result->SetBoolField(TEXT("rollbackSucceeded"), RollbackSucceeded.GetValue()); + } + return MCPResult(Result); +} + +#if UE_MCP_HAS_5_8_API + +constexpr int32 MaxChains = 256; +constexpr int32 MaxGoals = 256; +constexpr int32 MaxExclusions = 2048; + +struct FChainRequest +{ + FName Name; + FName StartBone; + FName EndBone; + FName Goal; +}; + +struct FGoalRequest +{ + FName Name; + FName Bone; + TOptional PositionAlpha; + TOptional RotationAlpha; + TOptional ChainDepth; + TOptional StrengthAlpha; + TOptional PullChainAlpha; + TOptional PinRotation; +}; + +struct FFullBodyRequest +{ + bool bPresent = false; + TOptional SolverIndex; + FName RootBone; + TOptional bEnabled; + TArray Goals; +}; + +struct FExclusionRequest +{ + FName Bone; + bool bExcluded = false; +}; + +static bool ReadRequiredString( + const TSharedPtr& Object, + const TCHAR* Field, + FString& Out, + FString& OutError) +{ + if (!Object.IsValid() || !Object->TryGetStringField(Field, Out)) + { + OutError = FString::Printf(TEXT("'%s' must be a string"), Field); + return false; + } + Out.TrimStartAndEndInline(); + if (Out.IsEmpty()) + { + OutError = FString::Printf(TEXT("'%s' must not be empty"), Field); + return false; + } + return true; +} + +static bool ReadOptionalUnitFloat( + const TSharedPtr& Object, + const TCHAR* Field, + TOptional& Out, + FString& OutError) +{ + if (!Object->HasField(Field)) + { + return true; + } + double Value = 0.0; + if (!Object->TryGetNumberField(Field, Value) || !FMath::IsFinite(Value) || Value < 0.0 || Value > 1.0) + { + OutError = FString::Printf(TEXT("'%s' must be a finite number in [0, 1]"), Field); + return false; + } + Out = static_cast(Value); + return true; +} + +static bool ReadOptionalNonNegativeInteger( + const TSharedPtr& Object, + const TCHAR* Field, + TOptional& Out, + FString& OutError) +{ + if (!Object->HasField(Field)) + { + return true; + } + double Value = 0.0; + if (!Object->TryGetNumberField(Field, Value) || !FMath::IsFinite(Value) || + Value < 0.0 || Value > static_cast(MAX_int32) || Value != FMath::TruncToDouble(Value)) + { + OutError = FString::Printf(TEXT("'%s' must be a non-negative integer"), Field); + return false; + } + Out = static_cast(Value); + return true; +} + +static bool ReadOptionalBool( + const TSharedPtr& Object, + const TCHAR* Field, + TOptional& Out, + FString& OutError) +{ + if (!Object->HasField(Field)) + { + return true; + } + bool Value = false; + if (!Object->TryGetBoolField(Field, Value)) + { + OutError = FString::Printf(TEXT("'%s' must be a boolean"), Field); + return false; + } + Out = Value; + return true; +} + +static bool IsFullBodySolver(const UIKRigController* Controller, const int32 SolverIndex) +{ + FInstancedStruct* SolverStruct = Controller->GetSolverStructAtIndex(SolverIndex); + return SolverStruct && SolverStruct->GetScriptStruct() == FIKRigFullBodyIKSolver::StaticStruct(); +} + +static FIKRigFullBodyIKSolver* GetFullBodySolver(const UIKRigController* Controller, const int32 SolverIndex) +{ + return IsFullBodySolver(Controller, SolverIndex) + ? static_cast(Controller->GetSolverAtIndex(SolverIndex)) + : nullptr; +} + +static bool GoalNameIsSafe(const FString& Name) +{ + FString Sanitized = Name; + UIKRigController::SanitizeGoalName(Sanitized); + return Sanitized == Name && !FName(*Name).IsNone(); +} + +#endif +} + +TSharedPtr FAnimationHandlers::ConfigureIKRig(const TSharedPtr& Params) +{ +#if !UE_MCP_HAS_5_8_API + return UE_MCP_IKRigAuthoring::Error( + TEXT("unsupported_engine_version"), + TEXT("IK Rig authoring requires Unreal Engine 5.8 or newer")); +#else + using namespace UE_MCP_IKRigAuthoring; + using UE_MCP_IKRigAuthoring::Error; + + if (!Params.IsValid()) + { + return Error(TEXT("invalid_params"), TEXT("Parameters are required")); + } + + FString ParseError; + FString RigPath; + if (!ReadRequiredString(Params, TEXT("rigPath"), RigPath, ParseError)) + { + return Error(TEXT("invalid_params"), ParseError); + } + if (MCPIsProtectedAssetPath(RigPath)) + { + return Error(TEXT("protected_asset"), FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RigPath)); + } + + FString AutoSetup; + if (Params->HasField(TEXT("autoSetup"))) + { + if (!Params->TryGetStringField(TEXT("autoSetup"), AutoSetup) || + (AutoSetup != TEXT("retarget") && AutoSetup != TEXT("full_body"))) + { + return Error(TEXT("invalid_params"), TEXT("'autoSetup' must be 'retarget' or 'full_body'")); + } + } + const bool bAutoRetarget = AutoSetup == TEXT("retarget") || AutoSetup == TEXT("full_body"); + const bool bAutoFullBody = AutoSetup == TEXT("full_body"); + + TOptional RetargetRoot; + if (Params->HasField(TEXT("retargetRoot"))) + { + FString Value; + if (!ReadRequiredString(Params, TEXT("retargetRoot"), Value, ParseError)) + { + return Error(TEXT("invalid_params"), ParseError); + } + RetargetRoot = FName(*Value); + } + + TOptional RootMotionBone; + if (Params->HasField(TEXT("rootMotionBone"))) + { + FString Value; + if (!ReadRequiredString(Params, TEXT("rootMotionBone"), Value, ParseError)) + { + return Error(TEXT("invalid_params"), ParseError); + } + RootMotionBone = FName(*Value); + } + + TArray Chains; + TSet RequestedChainNames; + if (Params->HasField(TEXT("chains"))) + { + const TArray>* Values = nullptr; + if (!Params->TryGetArrayField(TEXT("chains"), Values) || !Values || Values->Num() > MaxChains) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("'chains' must be an array with at most %d entries"), MaxChains)); + } + for (int32 Index = 0; Index < Values->Num(); ++Index) + { + if (!(*Values)[Index].IsValid() || (*Values)[Index]->Type != EJson::Object) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("chains[%d] must be an object"), Index)); + } + const TSharedPtr Object = (*Values)[Index]->AsObject(); + FString Name; + FString StartBone; + FString EndBone; + if (!ReadRequiredString(Object, TEXT("name"), Name, ParseError) || + !ReadRequiredString(Object, TEXT("startBone"), StartBone, ParseError) || + !ReadRequiredString(Object, TEXT("endBone"), EndBone, ParseError)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("chains[%d]: %s"), Index, *ParseError)); + } + FString Goal; + if (Object->HasField(TEXT("goal")) && !Object->TryGetStringField(TEXT("goal"), Goal)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("chains[%d].goal must be a string"), Index)); + } + Goal.TrimStartAndEndInline(); + FChainRequest Request{FName(*Name), FName(*StartBone), FName(*EndBone), Goal.IsEmpty() ? NAME_None : FName(*Goal)}; + if (Request.Name.IsNone() || RequestedChainNames.Contains(Request.Name)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("chains[%d].name must be non-empty and unique"), Index)); + } + RequestedChainNames.Add(Request.Name); + Chains.Add(Request); + } + } + + FFullBodyRequest FullBody; + if (Params->HasField(TEXT("fullBodyIK"))) + { + const TSharedPtr* ObjectPtr = nullptr; + if (!Params->TryGetObjectField(TEXT("fullBodyIK"), ObjectPtr) || !ObjectPtr || !ObjectPtr->IsValid()) + { + return Error(TEXT("invalid_params"), TEXT("'fullBodyIK' must be an object")); + } + const TSharedPtr Object = *ObjectPtr; + FullBody.bPresent = true; + FString RootBone; + if (!ReadRequiredString(Object, TEXT("rootBone"), RootBone, ParseError)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK: %s"), *ParseError)); + } + FullBody.RootBone = FName(*RootBone); + if (!ReadOptionalNonNegativeInteger(Object, TEXT("solverIndex"), FullBody.SolverIndex, ParseError) || + !ReadOptionalBool(Object, TEXT("enabled"), FullBody.bEnabled, ParseError)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK: %s"), *ParseError)); + } + + const TArray>* GoalValues = nullptr; + if (!Object->TryGetArrayField(TEXT("goals"), GoalValues) || !GoalValues || GoalValues->Num() > MaxGoals) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK.goals must be an array with at most %d entries"), MaxGoals)); + } + TSet RequestedGoalNames; + for (int32 Index = 0; Index < GoalValues->Num(); ++Index) + { + if (!(*GoalValues)[Index].IsValid() || (*GoalValues)[Index]->Type != EJson::Object) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK.goals[%d] must be an object"), Index)); + } + const TSharedPtr GoalObject = (*GoalValues)[Index]->AsObject(); + FString Name; + FString Bone; + if (!ReadRequiredString(GoalObject, TEXT("name"), Name, ParseError) || + !ReadRequiredString(GoalObject, TEXT("bone"), Bone, ParseError)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK.goals[%d]: %s"), Index, *ParseError)); + } + if (!GoalNameIsSafe(Name)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK.goals[%d].name is not a native-safe IK goal name"), Index)); + } + FGoalRequest Request; + Request.Name = FName(*Name); + Request.Bone = FName(*Bone); + if (RequestedGoalNames.Contains(Request.Name)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("Duplicate fullBodyIK goal '%s'"), *Name)); + } + RequestedGoalNames.Add(Request.Name); + if (!ReadOptionalUnitFloat(GoalObject, TEXT("positionAlpha"), Request.PositionAlpha, ParseError) || + !ReadOptionalUnitFloat(GoalObject, TEXT("rotationAlpha"), Request.RotationAlpha, ParseError) || + !ReadOptionalNonNegativeInteger(GoalObject, TEXT("chainDepth"), Request.ChainDepth, ParseError) || + !ReadOptionalUnitFloat(GoalObject, TEXT("strengthAlpha"), Request.StrengthAlpha, ParseError) || + !ReadOptionalUnitFloat(GoalObject, TEXT("pullChainAlpha"), Request.PullChainAlpha, ParseError) || + !ReadOptionalUnitFloat(GoalObject, TEXT("pinRotation"), Request.PinRotation, ParseError)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("fullBodyIK.goals[%d]: %s"), Index, *ParseError)); + } + FullBody.Goals.Add(Request); + } + } + + TArray Exclusions; + TSet RequestedExclusionBones; + if (Params->HasField(TEXT("exclusions"))) + { + const TArray>* Values = nullptr; + if (!Params->TryGetArrayField(TEXT("exclusions"), Values) || !Values || Values->Num() > MaxExclusions) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("'exclusions' must be an array with at most %d entries"), MaxExclusions)); + } + for (int32 Index = 0; Index < Values->Num(); ++Index) + { + if (!(*Values)[Index].IsValid() || (*Values)[Index]->Type != EJson::Object) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("exclusions[%d] must be an object"), Index)); + } + const TSharedPtr Object = (*Values)[Index]->AsObject(); + FString Bone; + bool bExcluded = false; + if (!ReadRequiredString(Object, TEXT("bone"), Bone, ParseError) || + !Object->TryGetBoolField(TEXT("excluded"), bExcluded)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("exclusions[%d] requires string 'bone' and boolean 'excluded'"), Index)); + } + const FName BoneName(*Bone); + if (RequestedExclusionBones.Contains(BoneName)) + { + return Error(TEXT("invalid_params"), FString::Printf(TEXT("Duplicate exclusion bone '%s'"), *Bone)); + } + RequestedExclusionBones.Add(BoneName); + Exclusions.Add({BoneName, bExcluded}); + } + } + + if (!bAutoRetarget && !RetargetRoot.IsSet() && !RootMotionBone.IsSet() && + !Params->HasField(TEXT("chains")) && !FullBody.bPresent && !Params->HasField(TEXT("exclusions"))) + { + return Error(TEXT("invalid_params"), TEXT("No IK Rig configuration was requested")); + } + + UIKRigDefinition* Rig = LoadAssetByPath(RigPath); + if (!Rig) + { + return Error(TEXT("asset_not_found"), FString::Printf(TEXT("IK Rig not found: %s"), *RigPath)); + } + UIKRigController* Controller = UIKRigController::GetController(Rig); + if (!Controller) + { + return Error(TEXT("controller_unavailable"), FString::Printf(TEXT("Could not acquire the native IK Rig controller for %s"), *RigPath)); + } + USkeletalMesh* Mesh = Controller->GetSkeletalMesh(); + const FIKRigSkeleton& Skeleton = Controller->GetIKRigSkeleton(); + if (!Mesh || Skeleton.BoneNames.IsEmpty() || !Controller->IsSkeletalMeshCompatible(Mesh)) + { + return Error(TEXT("incompatible_rig"), TEXT("The IK Rig must have a non-empty compatible skeletal mesh before it can be configured")); + } + + auto RequireBone = [&Skeleton](const FName Bone, const FString& Context, FString& OutError) -> bool + { + if (Bone.IsNone() || Skeleton.GetBoneIndexFromName(Bone) == INDEX_NONE) + { + OutError = FString::Printf(TEXT("%s references unknown bone '%s'"), *Context, *Bone.ToString()); + return false; + } + return true; + }; + + if ((RetargetRoot.IsSet() && !RequireBone(RetargetRoot.GetValue(), TEXT("retargetRoot"), ParseError)) || + (RootMotionBone.IsSet() && !RequireBone(RootMotionBone.GetValue(), TEXT("rootMotionBone"), ParseError)) || + (FullBody.bPresent && !RequireBone(FullBody.RootBone, TEXT("fullBodyIK.rootBone"), ParseError))) + { + return Error(TEXT("invalid_bone"), ParseError); + } + + TMap AvailableGoals; + for (const UIKRigEffectorGoal* Goal : Controller->GetAllGoals()) + { + if (Goal) + { + AvailableGoals.Add(Goal->GoalName, Goal->BoneName); + } + } + + FAutoCharacterizeResults AutoResults; + if (bAutoRetarget) + { + Controller->AutoGenerateRetargetDefinition(AutoResults); + if (!AutoResults.bUsedTemplate) + { + return Error(TEXT("auto_setup_unavailable"), TEXT("Unreal could not match this skeleton to a native IK Rig template")); + } + const FRetargetDefinition& Definition = AutoResults.AutoRetargetDefinition.RetargetDefinition; + if (!RequireBone(Definition.PelvisBone, TEXT("autoSetup retarget root"), ParseError)) + { + return Error(TEXT("invalid_bone"), ParseError); + } + for (const FBoneChain& Chain : Definition.BoneChains) + { + if (Chain.ChainName.IsNone() || + !RequireBone(Chain.StartBone.BoneName, FString::Printf(TEXT("auto chain '%s' start"), *Chain.ChainName.ToString()), ParseError) || + !RequireBone(Chain.EndBone.BoneName, FString::Printf(TEXT("auto chain '%s' end"), *Chain.ChainName.ToString()), ParseError) || + !Skeleton.IsBoneInDirectLineage(Chain.EndBone.BoneName, Chain.StartBone.BoneName)) + { + return Error(TEXT("invalid_chain"), ParseError.IsEmpty() + ? FString::Printf(TEXT("Auto chain '%s' has invalid ancestry"), *Chain.ChainName.ToString()) + : ParseError); + } + if (bAutoFullBody && !Chain.IKGoalName.IsNone()) + { + FString GoalName = Chain.IKGoalName.ToString(); + if (!GoalNameIsSafe(GoalName)) + { + return Error(TEXT("invalid_goal"), FString::Printf(TEXT("Auto goal '%s' is not a native-safe goal name"), *GoalName)); + } + AvailableGoals.Add(Chain.IKGoalName, Chain.EndBone.BoneName); + } + } + } + + if (bAutoFullBody && + (Controller->GetNumSolvers() != 0 || !Controller->GetAllGoals().IsEmpty() || + !Controller->GetRetargetChains().IsEmpty() || !Skeleton.ExcludedBones.IsEmpty() || + !Controller->GetRetargetRoot().IsNone() || !Controller->GetRootMotionBone().IsNone())) + { + return Error(TEXT("non_empty_rig"), TEXT("autoSetup 'full_body' is only safe on an empty IK Rig definition")); + } + + for (const FGoalRequest& Goal : FullBody.Goals) + { + if (!RequireBone(Goal.Bone, FString::Printf(TEXT("goal '%s'"), *Goal.Name.ToString()), ParseError)) + { + return Error(TEXT("invalid_bone"), ParseError); + } + if (Goal.ChainDepth.IsSet() && Goal.ChainDepth.GetValue() > Skeleton.BoneNames.Num()) + { + return Error(TEXT("invalid_range"), FString::Printf(TEXT("Goal '%s' chainDepth exceeds the skeleton bone count"), *Goal.Name.ToString())); + } + AvailableGoals.Add(Goal.Name, Goal.Bone); + } + + for (const FChainRequest& Chain : Chains) + { + if (!RequireBone(Chain.StartBone, FString::Printf(TEXT("chain '%s' start"), *Chain.Name.ToString()), ParseError) || + !RequireBone(Chain.EndBone, FString::Printf(TEXT("chain '%s' end"), *Chain.Name.ToString()), ParseError)) + { + return Error(TEXT("invalid_bone"), ParseError); + } + if (!Skeleton.IsBoneInDirectLineage(Chain.EndBone, Chain.StartBone)) + { + return Error(TEXT("invalid_chain"), FString::Printf(TEXT("Chain '%s' end bone must descend from its start bone"), *Chain.Name.ToString())); + } + if (!Chain.Goal.IsNone() && !AvailableGoals.Contains(Chain.Goal)) + { + return Error(TEXT("dangling_goal"), FString::Printf(TEXT("Chain '%s' references missing goal '%s'"), *Chain.Name.ToString(), *Chain.Goal.ToString())); + } + } + + TSet RequiredFBIKBones; + for (int32 Index = 0; Index < Controller->GetNumSolvers(); ++Index) + { + FIKRigFullBodyIKSolver* ExistingSolver = GetFullBodySolver(Controller, Index); + if (!ExistingSolver) continue; + const FName ExistingRoot = Controller->GetStartBone(Index); + if (!ExistingRoot.IsNone()) RequiredFBIKBones.Add(ExistingRoot); + TSet ConnectedGoals; + ExistingSolver->GetRequiredGoals(ConnectedGoals); + for (const FName GoalName : ConnectedGoals) + { + if (const FName* GoalBone = AvailableGoals.Find(GoalName)) RequiredFBIKBones.Add(*GoalBone); + } + } + if (FullBody.bPresent) + { + RequiredFBIKBones.Add(FullBody.RootBone); + for (const FGoalRequest& Goal : FullBody.Goals) RequiredFBIKBones.Add(Goal.Bone); + } + if (bAutoFullBody) + { + RequiredFBIKBones.Add(AutoResults.AutoRetargetDefinition.RetargetDefinition.PelvisBone); + for (const FBoneChain& Chain : AutoResults.AutoRetargetDefinition.RetargetDefinition.BoneChains) + { + if (const FName* GoalBone = AvailableGoals.Find(Chain.IKGoalName)) RequiredFBIKBones.Add(*GoalBone); + } + } + + for (const FExclusionRequest& Exclusion : Exclusions) + { + if (!RequireBone(Exclusion.Bone, TEXT("exclusions"), ParseError)) + { + return Error(TEXT("invalid_bone"), ParseError); + } + if (Exclusion.bExcluded && RequiredFBIKBones.Contains(Exclusion.Bone)) + { + return Error(TEXT("invalid_exclusion"), FString::Printf(TEXT("Bone '%s' cannot be excluded while an FBIK solver requires it as a root or goal bone"), *Exclusion.Bone.ToString())); + } + } + + int32 SolverIndex = INDEX_NONE; + bool bCreateSolver = false; + if (FullBody.bPresent || bAutoFullBody) + { + if (bAutoFullBody) + { + SolverIndex = 0; + if (FullBody.SolverIndex.IsSet() && FullBody.SolverIndex.GetValue() != 0) + { + return Error(TEXT("invalid_solver"), TEXT("autoSetup 'full_body' creates its FBIK solver at index 0")); + } + } + else if (FullBody.SolverIndex.IsSet()) + { + SolverIndex = FullBody.SolverIndex.GetValue(); + if (SolverIndex >= Controller->GetNumSolvers() || !IsFullBodySolver(Controller, SolverIndex)) + { + return Error(TEXT("invalid_solver"), FString::Printf(TEXT("Solver index %d is not an existing Full Body IK solver"), SolverIndex)); + } + } + else + { + for (int32 Index = 0; Index < Controller->GetNumSolvers(); ++Index) + { + if (IsFullBodySolver(Controller, Index)) + { + SolverIndex = Index; + break; + } + } + if (SolverIndex == INDEX_NONE) + { + SolverIndex = Controller->GetNumSolvers(); + bCreateSolver = true; + } + } + + const FName SolverRoot = FullBody.bPresent + ? FullBody.RootBone + : AutoResults.AutoRetargetDefinition.RetargetDefinition.PelvisBone; + TSet ConnectedGoals; + if (!bCreateSolver && !bAutoFullBody) + { + if (FIKRigFullBodyIKSolver* ExistingSolver = GetFullBodySolver(Controller, SolverIndex)) + { + ExistingSolver->GetRequiredGoals(ConnectedGoals); + } + } + if (bAutoFullBody) + { + for (const FBoneChain& Chain : AutoResults.AutoRetargetDefinition.RetargetDefinition.BoneChains) + { + if (!Chain.IKGoalName.IsNone()) ConnectedGoals.Add(Chain.IKGoalName); + } + } + for (const FGoalRequest& Goal : FullBody.Goals) ConnectedGoals.Add(Goal.Name); + for (const FName GoalName : ConnectedGoals) + { + const FName* GoalBone = AvailableGoals.Find(GoalName); + if (!GoalBone) + { + return Error(TEXT("dangling_goal"), FString::Printf(TEXT("FBIK solver references missing goal '%s'"), *GoalName.ToString())); + } + if (!Skeleton.IsBoneInDirectLineage(*GoalBone, SolverRoot)) + { + return Error(TEXT("invalid_ancestry"), FString::Printf(TEXT("Goal bone '%s' must descend from FBIK root '%s'"), *GoalBone->ToString(), *SolverRoot.ToString())); + } + } + } + + const int32 BeforeChainCount = Controller->GetRetargetChains().Num(); + const int32 BeforeGoalCount = Controller->GetAllGoals().Num(); + const int32 BeforeSolverCount = Controller->GetNumSolvers(); + const TSet BeforeExcluded(Skeleton.ExcludedBones); + UPackage* Package = Rig->GetOutermost(); + const bool bWasDirty = Package && Package->IsDirty(); + bool bFailed = false; + FString ApplyError; + TArray Warnings; + + { + FScopedTransaction Transaction(NSLOCTEXT("UE_MCP", "ConfigureIKRig", "Configure IK Rig")); + FScopedReinitializeIKRig Reinitialize(Controller, true); + Rig->Modify(); + + if (bAutoRetarget && !Controller->ApplyAutoGeneratedRetargetDefinition()) + { + bFailed = true; + ApplyError = TEXT("Native auto retarget setup failed"); + } + if (!bFailed && bAutoFullBody && !Controller->ApplyAutoFBIK()) + { + bFailed = true; + ApplyError = TEXT("Native auto Full Body IK setup failed"); + } + if (!bFailed && RetargetRoot.IsSet() && Controller->GetRetargetRoot() != RetargetRoot.GetValue() && + !Controller->SetRetargetRoot(RetargetRoot.GetValue())) + { + bFailed = true; + ApplyError = TEXT("Failed to set retargetRoot"); + } + if (!bFailed && RootMotionBone.IsSet() && Controller->GetRootMotionBone() != RootMotionBone.GetValue() && + !Controller->SetRootMotionBone(RootMotionBone.GetValue())) + { + bFailed = true; + ApplyError = TEXT("Failed to set rootMotionBone"); + } + + for (const FGoalRequest& Request : FullBody.Goals) + { + if (bFailed) break; + UIKRigEffectorGoal* Goal = Controller->GetGoal(Request.Name); + if (!Goal) + { + if (Controller->AddNewGoal(Request.Name, Request.Bone) != Request.Name) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to create goal '%s'"), *Request.Name.ToString()); + break; + } + Goal = Controller->GetGoal(Request.Name); + } + else if (Goal->BoneName != Request.Bone && !Controller->SetGoalBone(Request.Name, Request.Bone)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to move goal '%s'"), *Request.Name.ToString()); + break; + } + Goal = Controller->GetGoal(Request.Name); + const bool bCoreSettingsChanged = Goal && + ((Request.PositionAlpha.IsSet() && !FMath::IsNearlyEqual(Goal->PositionAlpha, Request.PositionAlpha.GetValue())) || + (Request.RotationAlpha.IsSet() && !FMath::IsNearlyEqual(Goal->RotationAlpha, Request.RotationAlpha.GetValue()))); + if (!Goal || (bCoreSettingsChanged && !Controller->ModifyGoal(Request.Name))) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to modify goal '%s'"), *Request.Name.ToString()); + break; + } + if (Request.PositionAlpha.IsSet()) Goal->PositionAlpha = Request.PositionAlpha.GetValue(); + if (Request.RotationAlpha.IsSet()) Goal->RotationAlpha = Request.RotationAlpha.GetValue(); + } + + for (const FChainRequest& Request : Chains) + { + if (bFailed) break; + const FBoneChain* Existing = Controller->GetRetargetChainByName(Request.Name); + if (!Existing) + { + if (Controller->AddRetargetChain(Request.Name, Request.StartBone, Request.EndBone, Request.Goal) != Request.Name) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to create chain '%s'"), *Request.Name.ToString()); + } + continue; + } + if (Existing->StartBone.BoneName != Request.StartBone && !Controller->SetRetargetChainStartBone(Request.Name, Request.StartBone)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to set chain '%s' start bone"), *Request.Name.ToString()); + break; + } + Existing = Controller->GetRetargetChainByName(Request.Name); + if (Existing->EndBone.BoneName != Request.EndBone && !Controller->SetRetargetChainEndBone(Request.Name, Request.EndBone)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to set chain '%s' end bone"), *Request.Name.ToString()); + break; + } + Existing = Controller->GetRetargetChainByName(Request.Name); + if (Existing->IKGoalName != Request.Goal && !Controller->SetRetargetChainGoal(Request.Name, Request.Goal)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to set chain '%s' goal"), *Request.Name.ToString()); + } + } + + if (!bFailed && (FullBody.bPresent || bAutoFullBody)) + { + if (bCreateSolver) + { + const int32 AddedIndex = Controller->AddSolver(FIKRigFullBodyIKSolver::StaticStruct()); + if (AddedIndex != SolverIndex) + { + bFailed = true; + ApplyError = TEXT("Failed to create the Full Body IK solver at the predicted index"); + } + } + FIKRigFullBodyIKSolver* Solver = bFailed ? nullptr : GetFullBodySolver(Controller, SolverIndex); + if (!Solver) + { + bFailed = true; + ApplyError = TEXT("Full Body IK solver readback failed"); + } + if (!bFailed && FullBody.bPresent) + { + if (Controller->GetStartBone(SolverIndex) != FullBody.RootBone && !Controller->SetStartBone(FullBody.RootBone, SolverIndex)) + { + bFailed = true; + ApplyError = TEXT("Failed to set the Full Body IK root bone"); + } + if (!bFailed && FullBody.bEnabled.IsSet() && Controller->GetSolverEnabled(SolverIndex) != FullBody.bEnabled.GetValue() && + !Controller->SetSolverEnabled(SolverIndex, FullBody.bEnabled.GetValue())) + { + bFailed = true; + ApplyError = TEXT("Failed to set the Full Body IK enabled state"); + } + for (const FGoalRequest& Request : FullBody.Goals) + { + if (bFailed) break; + if (!Controller->IsGoalConnectedToSolver(Request.Name, SolverIndex) && + !Controller->ConnectGoalToSolver(Request.Name, SolverIndex)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to connect goal '%s' to the Full Body IK solver"), *Request.Name.ToString()); + break; + } + FIKRigFBIKGoalSettings* Settings = static_cast(Solver->GetGoalSettings(Request.Name)); + if (!Settings) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Full Body IK settings are missing for goal '%s'"), *Request.Name.ToString()); + break; + } + if (Request.ChainDepth.IsSet()) Settings->ChainDepth = Request.ChainDepth.GetValue(); + if (Request.StrengthAlpha.IsSet()) Settings->StrengthAlpha = Request.StrengthAlpha.GetValue(); + if (Request.PullChainAlpha.IsSet()) Settings->PullChainAlpha = Request.PullChainAlpha.GetValue(); + if (Request.PinRotation.IsSet()) Settings->PinRotation = Request.PinRotation.GetValue(); + } + } + } + + for (const FExclusionRequest& Request : Exclusions) + { + if (bFailed) break; + if (Controller->GetBoneExcluded(Request.Bone) != Request.bExcluded && + !Controller->SetBoneExcluded(Request.Bone, Request.bExcluded)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Failed to set exclusion for bone '%s'"), *Request.Bone.ToString()); + } + } + + if (!bFailed && RetargetRoot.IsSet() && Controller->GetRetargetRoot() != RetargetRoot.GetValue()) + { + bFailed = true; + ApplyError = TEXT("retargetRoot readback did not match"); + } + if (!bFailed && RootMotionBone.IsSet() && Controller->GetRootMotionBone() != RootMotionBone.GetValue()) + { + bFailed = true; + ApplyError = TEXT("rootMotionBone readback did not match"); + } + for (const FChainRequest& Request : Chains) + { + if (bFailed) break; + const FBoneChain* Chain = Controller->GetRetargetChainByName(Request.Name); + TSet ChainIndices; + if (!Chain || Chain->StartBone.BoneName != Request.StartBone || Chain->EndBone.BoneName != Request.EndBone || + Chain->IKGoalName != Request.Goal || !Controller->ValidateChain(Request.Name, &Skeleton, ChainIndices)) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Chain '%s' failed native readback validation"), *Request.Name.ToString()); + } + } + for (const FGoalRequest& Request : FullBody.Goals) + { + if (bFailed) break; + const UIKRigEffectorGoal* Goal = Controller->GetGoal(Request.Name); + FIKRigFullBodyIKSolver* Solver = GetFullBodySolver(Controller, SolverIndex); + const FIKRigFBIKGoalSettings* Settings = Solver + ? static_cast(Solver->GetGoalSettings(Request.Name)) + : nullptr; + if (!Goal || Goal->BoneName != Request.Bone || !Controller->IsGoalConnectedToSolver(Request.Name, SolverIndex) || !Settings || + (Request.PositionAlpha.IsSet() && !FMath::IsNearlyEqual(Goal->PositionAlpha, Request.PositionAlpha.GetValue())) || + (Request.RotationAlpha.IsSet() && !FMath::IsNearlyEqual(Goal->RotationAlpha, Request.RotationAlpha.GetValue())) || + (Request.ChainDepth.IsSet() && Settings->ChainDepth != Request.ChainDepth.GetValue()) || + (Request.StrengthAlpha.IsSet() && !FMath::IsNearlyEqual(Settings->StrengthAlpha, Request.StrengthAlpha.GetValue())) || + (Request.PullChainAlpha.IsSet() && !FMath::IsNearlyEqual(Settings->PullChainAlpha, Request.PullChainAlpha.GetValue())) || + (Request.PinRotation.IsSet() && !FMath::IsNearlyEqual(Settings->PinRotation, Request.PinRotation.GetValue()))) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Goal '%s' failed native readback validation"), *Request.Name.ToString()); + } + } + for (const FExclusionRequest& Request : Exclusions) + { + if (!bFailed && Controller->GetBoneExcluded(Request.Bone) != Request.bExcluded) + { + bFailed = true; + ApplyError = FString::Printf(TEXT("Exclusion for bone '%s' failed readback"), *Request.Bone.ToString()); + } + } + } + + auto Undo = [&]() -> bool + { + const bool bUndone = GEditor && GEditor->UndoTransaction(); + if (bUndone && Package) + { + Package->SetDirtyFlag(bWasDirty); + FScopedReinitializeIKRig Refresh(Controller, true); + } + return bUndone; + }; + + if (bFailed) + { + const bool bUndone = Undo(); + return Error(TEXT("mutation_failed"), ApplyError, bUndone); + } + + Rig->MarkPackageDirty(); + if (!UEditorAssetLibrary::SaveLoadedAsset(Rig, false)) + { + const bool bUndone = Undo(); + return Error(TEXT("save_failed"), TEXT("IK Rig changes could not be saved"), bUndone); + } + + const FIKRigSkeleton& FinalSkeleton = Controller->GetIKRigSkeleton(); + const TSet FinalExcluded(FinalSkeleton.ExcludedBones); + int32 ExclusionsChanged = 0; + for (const FName Bone : BeforeExcluded) if (!FinalExcluded.Contains(Bone)) ++ExclusionsChanged; + for (const FName Bone : FinalExcluded) if (!BeforeExcluded.Contains(Bone)) ++ExclusionsChanged; + + TSharedPtr Result = MCPSuccess(); + MCPSetUpdated(Result); + Result->SetStringField(TEXT("rigPath"), Rig->GetPathName()); + Result->SetBoolField(TEXT("validated"), true); + Result->SetBoolField(TEXT("rollbackSafe"), true); + Result->SetBoolField(TEXT("autoSetupApplied"), bAutoRetarget); + if (!AutoSetup.IsEmpty()) Result->SetStringField(TEXT("autoSetup"), AutoSetup); + Result->SetStringField(TEXT("retargetRoot"), Controller->GetRetargetRoot().ToString()); + Result->SetStringField(TEXT("rootMotionBone"), Controller->GetRootMotionBone().ToString()); + Result->SetNumberField(TEXT("chainsCreated"), FMath::Max(0, Controller->GetRetargetChains().Num() - BeforeChainCount)); + Result->SetNumberField(TEXT("chainsUpserted"), Chains.Num()); + Result->SetNumberField(TEXT("goalsCreated"), FMath::Max(0, Controller->GetAllGoals().Num() - BeforeGoalCount)); + Result->SetNumberField(TEXT("goalsUpserted"), FullBody.Goals.Num()); + Result->SetNumberField(TEXT("exclusionsChanged"), ExclusionsChanged); + Result->SetNumberField(TEXT("chainCount"), Controller->GetRetargetChains().Num()); + Result->SetNumberField(TEXT("goalCount"), Controller->GetAllGoals().Num()); + Result->SetNumberField(TEXT("solverCount"), Controller->GetNumSolvers()); + if (SolverIndex != INDEX_NONE) + { + Result->SetNumberField(TEXT("fullBodyIKSolverIndex"), SolverIndex); + Result->SetBoolField(TEXT("fullBodyIKSolverCreated"), Controller->GetNumSolvers() > BeforeSolverCount); + } + TArray> WarningValues; + for (const FString& Warning : Warnings) WarningValues.Add(MakeShared(Warning)); + Result->SetArrayField(TEXT("warnings"), WarningValues); + return MCPResult(Result); +#endif +} diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp index 6b43547d..99d0adc7 100644 --- a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp @@ -36,6 +36,12 @@ #include "AnimGraphNode_BlendSpacePlayer.h" #include "Rig/IKRigDefinition.h" #include "RigEditor/IKRigController.h" +#if UE_MCP_HAS_5_8_API +#include "Rig/Solvers/IKRigFullBodyIK.h" +#include "Retargeter/IKRetargetChainMapping.h" +#include "Retargeter/IKRetargetOps.h" +#include "Retargeter/IKRetargetProcessor.h" +#endif #include "PoseSearch/PoseSearchDatabase.h" #include "PoseSearch/PoseSearchSchema.h" #include "PoseSearch/PoseSearchDerivedData.h" @@ -53,12 +59,82 @@ #include "UObject/UObjectGlobals.h" #include "EditorAssetLibrary.h" #include "Editor.h" +#include "ScopedTransaction.h" #include "Dom/JsonObject.h" #include "Dom/JsonValue.h" #define UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API (ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 4)) +#if UE_MCP_HAS_5_8_API +static TSharedPtr AnimationVectorToJson(const FVector& Value) +{ + TSharedPtr Json = MakeShared(); + Json->SetNumberField(TEXT("x"), Value.X); + Json->SetNumberField(TEXT("y"), Value.Y); + Json->SetNumberField(TEXT("z"), Value.Z); + return Json; +} + +static TSharedPtr AnimationQuaternionToJson(const FQuat& Value) +{ + TSharedPtr Json = MakeShared(); + Json->SetNumberField(TEXT("x"), Value.X); + Json->SetNumberField(TEXT("y"), Value.Y); + Json->SetNumberField(TEXT("z"), Value.Z); + Json->SetNumberField(TEXT("w"), Value.W); + return Json; +} + +static TSharedPtr AnimationTransformToJson(const FTransform& Value) +{ + TSharedPtr Json = MakeShared(); + Json->SetObjectField(TEXT("translation"), AnimationVectorToJson(Value.GetTranslation())); + Json->SetObjectField(TEXT("rotationQuaternion"), AnimationQuaternionToJson(Value.GetRotation())); + Json->SetObjectField(TEXT("scale"), AnimationVectorToJson(Value.GetScale3D())); + return Json; +} + +// Processor initialization temporarily redirects editor-instance pointers on +// the serialized op stack. Restore them after validation and batch execution. +class FScopedBatchRetargetEditorInstanceRestore +{ + struct FState + { + FIKRetargetOpBase* Op = nullptr; + FIKRetargetOpBase* OpEditorInstance = nullptr; + FIKRetargetOpSettingsBase* Settings = nullptr; + FIKRetargetOpSettingsBase* SettingsEditorInstance = nullptr; + }; + +public: + explicit FScopedBatchRetargetEditorInstanceRestore(UIKRetargeter* Retargeter) + { + if (!Retargeter) return; + States.Reserve(Retargeter->GetRetargetOps().Num()); + for (const FInstancedStruct& OpStruct : Retargeter->GetRetargetOps()) + { + FIKRetargetOpBase* Op = const_cast(OpStruct.GetPtr()); + if (!Op) continue; + FIKRetargetOpSettingsBase* Settings = Op->GetSettings(); + States.Add({Op, Op->EditorInstance, Settings, Settings ? Settings->EditorInstance : nullptr}); + } + } + + ~FScopedBatchRetargetEditorInstanceRestore() + { + for (const FState& State : States) + { + State.Op->EditorInstance = State.OpEditorInstance; + if (State.Settings) State.Settings->EditorInstance = State.SettingsEditorInstance; + } + } + +private: + TArray States; +}; +#endif + static int32 GetPoseSearchAnimationAssetCount(const UPoseSearchDatabase* Database) { #if UE_MCP_HAS_POSESEARCH_DATABASE_ASSET_API @@ -1278,6 +1354,9 @@ TSharedPtr FAnimationHandlers::ReadIKRig(const TSharedPtrSetStringField(TEXT("name"), Chain.ChainName.ToString()); ChainObj->SetStringField(TEXT("startBone"), Chain.StartBone.BoneName.ToString()); ChainObj->SetStringField(TEXT("endBone"), Chain.EndBone.BoneName.ToString()); +#if UE_MCP_HAS_5_8_API + ChainObj->SetStringField(TEXT("goal"), Chain.IKGoalName.ToString()); +#endif ChainsArray.Add(MakeShared(ChainObj)); } Result->SetArrayField(TEXT("retargetChains"), ChainsArray); @@ -1292,8 +1371,95 @@ TSharedPtr FAnimationHandlers::ReadIKRig(const TSharedPtrSetStringField(TEXT("rootBone"), RigSkeleton.BoneNames[0].ToString()); } - // Solvers - enumerate via reflection since GetSolverArray not available in all UE versions + // UE 5.8 replaced the legacy UObject solver array with typed instanced + // structs. Keep the reflection fallback for older supported engines. TArray> SolversArray; +#if UE_MCP_HAS_5_8_API + UIKRigController* Controller = UIKRigController::GetController(IKRig); + if (!Controller) + { + return MCPError(TEXT("IKRigController unavailable")); + } + + Result->SetStringField(TEXT("retargetRoot"), Controller->GetRetargetRoot().ToString()); + Result->SetStringField(TEXT("rootMotionBone"), Controller->GetRootMotionBone().ToString()); + Result->SetStringField( + TEXT("skeletonRootBone"), + RigSkeleton.BoneNames.IsEmpty() ? TEXT("") : RigSkeleton.BoneNames[0].ToString()); + + TArray> GoalsArray; + for (UIKRigEffectorGoal* Goal : Controller->GetAllGoals()) + { + if (!Goal) continue; + TSharedPtr GoalObj = MakeShared(); + GoalObj->SetStringField(TEXT("name"), Goal->GoalName.ToString()); + GoalObj->SetStringField(TEXT("bone"), Goal->BoneName.ToString()); + GoalObj->SetNumberField(TEXT("positionAlpha"), Goal->PositionAlpha); + GoalObj->SetNumberField(TEXT("rotationAlpha"), Goal->RotationAlpha); + GoalObj->SetObjectField(TEXT("currentTransform"), AnimationTransformToJson(Goal->CurrentTransform)); + GoalObj->SetObjectField(TEXT("initialTransform"), AnimationTransformToJson(Goal->InitialTransform)); + + TArray> ConnectedSolvers; + for (int32 SolverIndex = 0; SolverIndex < Controller->GetNumSolvers(); ++SolverIndex) + { + if (Controller->IsGoalConnectedToSolver(Goal->GoalName, SolverIndex)) + { + ConnectedSolvers.Add(MakeShared(SolverIndex)); + } + } + GoalObj->SetArrayField(TEXT("connectedSolverIndices"), ConnectedSolvers); + GoalsArray.Add(MakeShared(GoalObj)); + } + Result->SetArrayField(TEXT("goals"), GoalsArray); + + TArray> ExcludedBones; + for (const FName BoneName : RigSkeleton.BoneNames) + { + if (Controller->GetBoneExcluded(BoneName)) + { + ExcludedBones.Add(MakeShared(BoneName.ToString())); + } + } + Result->SetArrayField(TEXT("excludedBones"), ExcludedBones); + + for (int32 SolverIndex = 0; SolverIndex < Controller->GetNumSolvers(); ++SolverIndex) + { + FInstancedStruct* SolverStruct = Controller->GetSolverStructAtIndex(SolverIndex); + const UScriptStruct* SolverType = SolverStruct ? SolverStruct->GetScriptStruct() : nullptr; + TSharedPtr SolverObj = MakeShared(); + SolverObj->SetNumberField(TEXT("index"), SolverIndex); + SolverObj->SetStringField(TEXT("name"), Controller->GetSolverUniqueName(SolverIndex)); + SolverObj->SetStringField(TEXT("type"), SolverType ? SolverType->GetPathName() : TEXT("")); + SolverObj->SetBoolField(TEXT("enabled"), Controller->GetSolverEnabled(SolverIndex)); + SolverObj->SetStringField(TEXT("startBone"), Controller->GetStartBone(SolverIndex).ToString()); + SolverObj->SetStringField(TEXT("endBone"), Controller->GetEndBone(SolverIndex).ToString()); + + TArray> Effectors; + if (UIKRigFBIKController* FBIKController = Cast(Controller->GetSolverController(SolverIndex))) + { + SolverObj->SetBoolField(TEXT("fullBodyIK"), true); + for (UIKRigEffectorGoal* Goal : Controller->GetAllGoals()) + { + if (!Goal || !Controller->IsGoalConnectedToSolver(Goal->GoalName, SolverIndex)) continue; + const FIKRigFBIKGoalSettings Settings = FBIKController->GetGoalSettings(Goal->GoalName); + TSharedPtr EffectorObj = MakeShared(); + EffectorObj->SetStringField(TEXT("goal"), Goal->GoalName.ToString()); + EffectorObj->SetStringField(TEXT("bone"), Settings.BoneName.ToString()); + EffectorObj->SetNumberField(TEXT("chainDepth"), Settings.ChainDepth); + EffectorObj->SetNumberField(TEXT("strengthAlpha"), Settings.StrengthAlpha); + EffectorObj->SetNumberField(TEXT("pullChainAlpha"), Settings.PullChainAlpha); + EffectorObj->SetNumberField(TEXT("pinRotation"), Settings.PinRotation); + Effectors.Add(MakeShared(EffectorObj)); + } + } + else + { + SolverObj->SetBoolField(TEXT("fullBodyIK"), false); + } + SolverObj->SetArrayField(TEXT("effectors"), Effectors); + SolversArray.Add(MakeShared(SolverObj)); + } +#else FProperty* SolversProp = IKRig->GetClass()->FindPropertyByName(TEXT("Solvers")); if (SolversProp) { @@ -1307,6 +1473,7 @@ TSharedPtr FAnimationHandlers::ReadIKRig(const TSharedPtr(SolverInfo)); } } +#endif Result->SetArrayField(TEXT("solvers"), SolversArray); return MCPResult(Result); @@ -1340,7 +1507,78 @@ TSharedPtr FAnimationHandlers::CreateIKRetargeter(const TSharedPtr(Name, PackagePath, OnConflict, TEXT("IKRetargeter"), RetargeterClass, Factory); if (Created.EarlyReturn) return Created.EarlyReturn; UObject* NewAsset = Created.Asset; + const bool bAutoMap = OptionalBool(Params, TEXT("autoMapChains"), true); + int32 ChainsMapped = 0; + FString SrcErr; + FString TgtErr; + FString OpsWarning; + +#if UE_MCP_HAS_5_8_API + UIKRetargeter* Retargeter = Cast(NewAsset); + if (!Retargeter) + { + UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName()); + return MCPError(TEXT("Created asset is not an IKRetargeter")); + } + + UIKRigDefinition* SourceRig = SourceRigPath.IsEmpty() + ? nullptr + : LoadObject(nullptr, *SourceRigPath); + UIKRigDefinition* TargetRig = TargetRigPath.IsEmpty() + ? nullptr + : LoadObject(nullptr, *TargetRigPath); + if (!SourceRigPath.IsEmpty() && !SourceRig) + { + UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName()); + return MCPError(FString::Printf(TEXT("IKRig not found: %s"), *SourceRigPath)); + } + if (!TargetRigPath.IsEmpty() && !TargetRig) + { + UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName()); + return MCPError(FString::Printf(TEXT("IKRig not found: %s"), *TargetRigPath)); + } + + UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter); + if (!Controller) + { + UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName()); + return MCPError(TEXT("IKRetargeterController unavailable")); + } + // The factory does not install the operational stack. AddDefaultOps is + // idempotent and must precede per-op rig assignment and chain mapping. + Controller->AddDefaultOps(); + if (SourceRig) + { + Controller->SetIKRig(ERetargetSourceOrTarget::Source, SourceRig); + Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Source, SourceRig); + } + if (TargetRig) + { + Controller->SetIKRig(ERetargetSourceOrTarget::Target, TargetRig); + Controller->AssignIKRigToAllOps(ERetargetSourceOrTarget::Target, TargetRig); + } + if (bAutoMap) + { + Controller->AutoMapChains(EAutoMapChainType::Exact, true); + } + Controller->CleanAsset(); + + if ((SourceRig && Controller->GetIKRig(ERetargetSourceOrTarget::Source) != SourceRig) + || (TargetRig && Controller->GetIKRig(ERetargetSourceOrTarget::Target) != TargetRig)) + { + UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName()); + return MCPError(TEXT("IK Retargeter rig assignment failed readback validation")); + } + + if (TargetRig) + { + for (const FBoneChain& Chain : TargetRig->GetRetargetChains()) + { + if (!Controller->GetSourceChain(Chain.ChainName).IsNone()) ++ChainsMapped; + } + } +#else // Optionally set source / target IK Rigs via reflection auto SetRigProperty = [&](const FString& PropName, const FString& Path) -> FString { @@ -1354,16 +1592,13 @@ TSharedPtr FAnimationHandlers::CreateIKRetargeter(const TSharedPtrImportText_Direct(*Export, Addr, NewAsset, PPF_None) ? TEXT("") : FString::Printf(TEXT("Failed to set %s"), *PropName); }; - FString SrcErr = SetRigProperty(TEXT("SourceIKRigAsset"), SourceRigPath); - FString TgtErr = SetRigProperty(TEXT("TargetIKRigAsset"), TargetRigPath); + SrcErr = SetRigProperty(TEXT("SourceIKRigAsset"), SourceRigPath); + TgtErr = SetRigProperty(TEXT("TargetIKRigAsset"), TargetRigPath); // UE 5.7+ ops-stack initialization (#246). After CreateAsset the per-op // IK Rig refs and chain mappings are unset, so the retargeter cannot be // driven by an Anim Graph. Mirror what the Python workaround does: // AssignIKRigToAllOps(SOURCE/TARGET) + AutoMapChains. - bool bAutoMap = OptionalBool(Params, TEXT("autoMapChains"), true); - int32 ChainsMapped = 0; - FString OpsWarning; if (bAutoMap) { UIKRetargeter* Retargeter = Cast(NewAsset); @@ -1431,9 +1666,14 @@ TSharedPtr FAnimationHandlers::CreateIKRetargeter(const TSharedPtrMarkPackageDirty(); - UEditorAssetLibrary::SaveAsset(NewAsset->GetPathName()); + if (!SaveAssetPackage(NewAsset)) + { + const FString FailedPackage = NewAsset->GetOutermost()->GetName(); + UEditorAssetLibrary::DeleteAsset(NewAsset->GetPathName()); + return MCPError(FString::Printf(TEXT("Failed to save IKRetargeter package '%s'"), *FailedPackage)); + } auto Result = MCPSuccess(); MCPSetCreated(Result); @@ -1477,6 +1717,93 @@ TSharedPtr FAnimationHandlers::ReadIKRetargeter(const TSharedPtrSetStringField(TEXT("sourceRig"), SrcRig ? SrcRig->GetPathName() : TEXT("")); Result->SetStringField(TEXT("targetRig"), TgtRig ? TgtRig->GetPathName() : TEXT("")); +#if UE_MCP_HAS_5_8_API + USkeletalMesh* SourcePreviewMesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Source); + USkeletalMesh* TargetPreviewMesh = Controller->GetPreviewMesh(ERetargetSourceOrTarget::Target); + Result->SetStringField(TEXT("sourcePreviewMesh"), SourcePreviewMesh ? SourcePreviewMesh->GetPathName() : TEXT("")); + Result->SetStringField(TEXT("targetPreviewMesh"), TargetPreviewMesh ? TargetPreviewMesh->GetPathName() : TEXT("")); + + auto WritePoses = [&](const ERetargetSourceOrTarget Side, const TCHAR* CurrentField, const TCHAR* PosesField) + { + const FName CurrentPoseName = Controller->GetCurrentRetargetPoseName(Side); + Result->SetStringField(CurrentField, CurrentPoseName.ToString()); + + TMap& Poses = Controller->GetRetargetPoses(Side); + TArray PoseNames; + Poses.GetKeys(PoseNames); + PoseNames.Sort([](const FName& A, const FName& B) + { + return A.ToString() < B.ToString(); + }); + + TArray> PosesArray; + for (const FName PoseName : PoseNames) + { + const FIKRetargetPose* Pose = Poses.Find(PoseName); + if (!Pose) continue; + TSharedPtr PoseObj = MakeShared(); + PoseObj->SetStringField(TEXT("name"), PoseName.ToString()); + PoseObj->SetBoolField(TEXT("current"), PoseName == CurrentPoseName); + PoseObj->SetObjectField(TEXT("rootTranslationOffset"), AnimationVectorToJson(Pose->GetRootTranslationDelta())); + + TArray OffsetBones; + Pose->GetAllDeltaRotations().GetKeys(OffsetBones); + OffsetBones.Sort([](const FName& A, const FName& B) + { + return A.ToString() < B.ToString(); + }); + + TArray> RotationOffsets; + for (const FName BoneName : OffsetBones) + { + const FQuat* Rotation = Pose->GetAllDeltaRotations().Find(BoneName); + if (!Rotation) continue; + TSharedPtr OffsetObj = MakeShared(); + OffsetObj->SetStringField(TEXT("bone"), BoneName.ToString()); + OffsetObj->SetObjectField(TEXT("rotationQuaternion"), AnimationQuaternionToJson(*Rotation)); + RotationOffsets.Add(MakeShared(OffsetObj)); + } + PoseObj->SetArrayField(TEXT("rotationOffsets"), RotationOffsets); + PosesArray.Add(MakeShared(PoseObj)); + } + Result->SetArrayField(PosesField, PosesArray); + }; + + WritePoses(ERetargetSourceOrTarget::Source, TEXT("currentSourcePose"), TEXT("sourcePoses")); + WritePoses(ERetargetSourceOrTarget::Target, TEXT("currentTargetPose"), TEXT("targetPoses")); + + TArray> RetargetOps; + for (int32 OpIndex = 0; OpIndex < Controller->GetNumRetargetOps(); ++OpIndex) + { + const FName OpName = Controller->GetOpName(OpIndex); + FInstancedStruct* OpStruct = Controller->GetRetargetOpStructAtIndex(OpIndex); + const UScriptStruct* OpType = OpStruct ? OpStruct->GetScriptStruct() : nullptr; + TSharedPtr OpObj = MakeShared(); + OpObj->SetNumberField(TEXT("index"), OpIndex); + OpObj->SetStringField(TEXT("name"), OpName.ToString()); + OpObj->SetStringField(TEXT("type"), OpType ? OpType->GetPathName() : TEXT("")); + OpObj->SetBoolField(TEXT("enabled"), Controller->GetRetargetOpEnabled(OpIndex)); + OpObj->SetStringField(TEXT("parentOp"), Controller->GetParentOpByName(OpName).ToString()); + const UIKRigDefinition* OpTargetRig = Controller->GetTargetIKRigForOp(OpName); + OpObj->SetStringField(TEXT("targetRig"), OpTargetRig ? OpTargetRig->GetPathName() : TEXT("")); + + TArray> OpMappings; + if (const FRetargetChainMapping* ChainMapping = Controller->GetChainMapping(OpName)) + { + for (const FRetargetChainPair& Pair : ChainMapping->GetChainPairs()) + { + TSharedPtr MappingObj = MakeShared(); + MappingObj->SetStringField(TEXT("targetChain"), Pair.TargetChainName.ToString()); + MappingObj->SetStringField(TEXT("sourceChain"), Pair.SourceChainName.ToString()); + OpMappings.Add(MakeShared(MappingObj)); + } + } + OpObj->SetArrayField(TEXT("chainMappings"), OpMappings); + RetargetOps.Add(MakeShared(OpObj)); + } + Result->SetArrayField(TEXT("retargetOps"), RetargetOps); +#endif + // Chain mappings: for each target chain, report the source chain it's mapped to. TArray> Mappings; if (TgtRig) @@ -1841,6 +2168,10 @@ TSharedPtr FAnimationHandlers::SetIKRigMesh(const TSharedPtr FAnimationHandlers::SetIKRetargeterRig(const TSharedPtr(nullptr, *RetargeterPath); if (!Retargeter) return MCPError(FString::Printf(TEXT("IKRetargeter not found: %s"), *RetargeterPath)); @@ -1888,8 +2228,38 @@ TSharedPtr FAnimationHandlers::SetIKRetargeterRig(const TSharedPtrSetIKRig(ParseSourceOrTarget(Side), IKRig); - SaveAssetPackage(Retargeter); + const ERetargetSourceOrTarget SourceOrTarget = ParseSourceOrTarget(Side); + bool bAssignmentFailed = false; + { + const FScopedTransaction Transaction(NSLOCTEXT("UE_MCP", "SetIKRetargeterRig", "Set IK Retargeter Rig")); + Retargeter->Modify(); +#if UE_MCP_HAS_5_8_API + if (Controller->GetNumRetargetOps() == 0) + { + Controller->AddDefaultOps(); + } + Controller->SetIKRig(SourceOrTarget, IKRig); + Controller->AssignIKRigToAllOps(SourceOrTarget, IKRig); + Controller->CleanAsset(); +#else + Controller->SetIKRig(SourceOrTarget, IKRig); +#endif + bAssignmentFailed = Controller->GetIKRig(SourceOrTarget) != IKRig; + } + if (bAssignmentFailed) + { + const bool bRolledBack = GEditor && GEditor->UndoTransaction(); + return MCPError(bRolledBack + ? TEXT("IK Retargeter rig assignment failed readback validation and was rolled back") + : TEXT("IK Retargeter rig assignment failed readback validation and could not be rolled back")); + } + if (!SaveAssetPackage(Retargeter)) + { + const bool bRolledBack = GEditor && GEditor->UndoTransaction(); + return MCPError(bRolledBack + ? TEXT("IK Retargeter rig assignment could not be saved and was rolled back") + : TEXT("IK Retargeter rig assignment could not be saved and the editor transaction could not be rolled back")); + } auto Result = MCPSuccess(); MCPSetUpdated(Result); @@ -1904,6 +2274,10 @@ TSharedPtr FAnimationHandlers::AutoAlignRetargetPose(const TSharedPt { FString RetargeterPath; if (auto Err = RequireStringAlt(Params, TEXT("retargeterPath"), TEXT("assetPath"), RetargeterPath)) return Err; + if (MCPIsProtectedAssetPath(RetargeterPath)) + { + return MCPError(FString::Printf(TEXT("Protected asset cannot be modified: %s"), *RetargeterPath)); + } const FString Side = OptionalString(Params, TEXT("side"), TEXT("target")); UIKRetargeter* Retargeter = LoadObject(nullptr, *RetargeterPath); @@ -1927,6 +2301,10 @@ TSharedPtr FAnimationHandlers::ResetRetargetPose(const TSharedPtr(nullptr, *RetargeterPath); @@ -1950,6 +2328,13 @@ TSharedPtr FAnimationHandlers::ResetRetargetPose(const TSharedPtr FAnimationHandlers::BatchRetargetAnimations(const TSharedPtr& Params) { +#if !(ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 8)) + auto Result = MakeShared(); + Result->SetBoolField(TEXT("success"), false); + Result->SetStringField(TEXT("errorCode"), TEXT("unsupported_engine_version")); + Result->SetStringField(TEXT("error"), TEXT("batch_retarget_animations requires Unreal Engine 5.8 or newer")); + return MCPResult(Result); +#else FString RetargeterPath; if (auto Err = RequireStringAlt(Params, TEXT("retargeterPath"), TEXT("assetPath"), RetargeterPath)) return Err; FString SourceMeshPath, TargetMeshPath; @@ -1962,6 +2347,46 @@ TSharedPtr FAnimationHandlers::BatchRetargetAnimations(const TShared if (!SourceMesh) return MCPError(FString::Printf(TEXT("Source mesh not found: %s"), *SourceMeshPath)); USkeletalMesh* TargetMesh = LoadObject(nullptr, *TargetMeshPath); if (!TargetMesh) return MCPError(FString::Printf(TEXT("Target mesh not found: %s"), *TargetMeshPath)); + if (SourceMesh == TargetMesh) return MCPError(TEXT("sourceMesh and targetMesh must be different")); + if (OptionalBool(Params, TEXT("overwrite"), false)) + { + return MCPError(TEXT("overwrite=true is not supported; choose a new output name/path so existing assets are never replaced")); + } + + bool bMappingInspectionAvailable = false; + int32 TargetChainCount = 0; + int32 MappedChainCount = 0; + TArray> UnmappedTargetChains; + if (UIKRetargeterController* Controller = UIKRetargeterController::GetController(Retargeter)) + { + if (const UIKRigDefinition* TargetRig = Controller->GetIKRig(ERetargetSourceOrTarget::Target)) + { + bMappingInspectionAvailable = true; + for (const FBoneChain& TargetChain : TargetRig->GetRetargetChains()) + { + ++TargetChainCount; + if (Controller->GetSourceChain(TargetChain.ChainName).IsNone()) + { + UnmappedTargetChains.Add(MakeShared(TargetChain.ChainName.ToString())); + } + else + { + ++MappedChainCount; + } + } + } + } + const bool bRequireCompleteMapping = OptionalBool(Params, TEXT("requireCompleteMapping"), false); + if (bRequireCompleteMapping && !bMappingInspectionAvailable) + { + return MCPError(TEXT("Cannot verify complete mapping because the retargeter has no target IK Rig")); + } + if (bRequireCompleteMapping && !UnmappedTargetChains.IsEmpty()) + { + return MCPError(FString::Printf( + TEXT("Retargeter has %d unmapped target chain(s) and requireCompleteMapping=true"), + UnmappedTargetChains.Num())); + } const TArray>* AnimArr = nullptr; if (!Params->TryGetArrayField(TEXT("animPaths"), AnimArr) || !AnimArr || AnimArr->Num() == 0) @@ -1969,40 +2394,123 @@ TSharedPtr FAnimationHandlers::BatchRetargetAnimations(const TShared return MCPError(TEXT("Missing 'animPaths' (array of AnimSequence paths to retarget)")); } - // The FIKRetargetBatchOperationInputs / UIKRetargetBatchOperation::RunBatchRetarget - // batch API is UE 5.8+. The 5.7 batch-retarget API differs; rather than a - // partial reimplementation, return a clear error below 5.8. -#if !(ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 8)) - return MCPError(TEXT("batch_retarget_animations requires UE 5.8+ (the FIKRetargetBatchOperationInputs / RunBatchRetarget API is unavailable in this engine version).")); -#else FIKRetargetBatchOperationInputs Inputs; Inputs.SourceMesh = SourceMesh; Inputs.TargetMesh = TargetMesh; Inputs.IKRetargetAsset = Retargeter; - Inputs.bOverwriteExistingFiles = OptionalBool(Params, TEXT("overwrite"), false); + Inputs.bOverwriteExistingFiles = false; + Inputs.bIncludeReferencedAssets = false; Inputs.Prefix = OptionalString(Params, TEXT("prefix")); Inputs.Suffix = OptionalString(Params, TEXT("suffix"), TEXT("_Retargeted")); const FString TargetPath = OptionalString(Params, TEXT("outputPath")); + if (!TargetPath.IsEmpty() && MCPIsProtectedAssetPath(TargetPath)) + { + return MCPError(FString::Printf(TEXT("Protected output path is not allowed: %s"), *TargetPath)); + } if (TargetPath.IsEmpty()) { Inputs.bUseSourcePath = true; } else { Inputs.TargetPath = TargetPath; } + TSet SeenPaths; int32 Loaded = 0; - for (const TSharedPtr& V : *AnimArr) + for (int32 Index = 0; Index < AnimArr->Num(); ++Index) { + const TSharedPtr& V = (*AnimArr)[Index]; FString P; - if (!V->TryGetString(P) || P.IsEmpty()) continue; - if (UAnimSequence* Anim = LoadObject(nullptr, *P)) + if (!V.IsValid() || !V->TryGetString(P) || P.IsEmpty()) { - Inputs.AssetsToRetarget.Add(FAssetData(Anim)); - ++Loaded; + return MCPError(FString::Printf(TEXT("animPaths[%d] must be a non-empty AnimSequence asset path"), Index)); } + if (SeenPaths.Contains(P)) + { + return MCPError(FString::Printf(TEXT("Duplicate animPaths entry: %s"), *P)); + } + if (TargetPath.IsEmpty() && MCPIsProtectedAssetPath(P)) + { + return MCPError(FString::Printf(TEXT("Cannot create a retargeted asset beside protected source path: %s"), *P)); + } + UAnimSequence* Anim = LoadObject(nullptr, *P); + if (!Anim) + { + return MCPError(FString::Printf(TEXT("AnimSequence not found: %s"), *P)); + } + if (!SourceMesh->GetSkeleton() || !Anim->GetSkeleton() + || !SourceMesh->GetSkeleton()->IsCompatibleForEditor(Anim->GetSkeleton())) + { + return MCPError(FString::Printf( + TEXT("AnimSequence skeleton is incompatible with sourceMesh: %s"), *P)); + } + SeenPaths.Add(P); + Inputs.AssetsToRetarget.Add(FAssetData(Anim)); + ++Loaded; + } + + FScopedBatchRetargetEditorInstanceRestore RestoreEditorInstances(Retargeter); + FIKRetargetProcessor ValidationProcessor; + FRetargetInitParameters ValidationParameters; + ValidationParameters.SourceSkeletalMesh = SourceMesh; + ValidationParameters.TargetSkeletalMesh = TargetMesh; + ValidationParameters.RetargeterAsset = Retargeter; + ValidationParameters.bSuppressWarnings = false; + ValidationProcessor.Initialize(ValidationParameters); + const TArray ValidationErrors = ValidationProcessor.Log.GetErrors(); + if (!ValidationProcessor.IsInitialized() || !ValidationErrors.IsEmpty()) + { + return MCPError(!ValidationErrors.IsEmpty() + ? FString::Printf(TEXT("IK Retargeter processor validation failed: %s"), *ValidationErrors[0].ToString()) + : TEXT("IK Retargeter processor validation failed to initialize")); } - if (Loaded == 0) return MCPError(TEXT("No valid AnimSequences resolved from animPaths")); const TArray Created = UIKRetargetBatchOperation::RunBatchRetarget(Inputs); + auto DeleteCreatedAssets = [&Created]() + { + TArray ResidualPaths; + for (const FAssetData& AssetData : Created) + { + const FString ObjectPath = AssetData.GetObjectPathString(); + if (ObjectPath.IsEmpty()) + { + ResidualPaths.Add(TEXT("")); + continue; + } + UEditorAssetLibrary::DeleteAsset(ObjectPath); + if (UEditorAssetLibrary::DoesAssetExist(ObjectPath)) ResidualPaths.Add(ObjectPath); + } + return ResidualPaths; + }; + if (Created.Num() != Loaded) + { + const TArray ResidualPaths = DeleteCreatedAssets(); + if (!ResidualPaths.IsEmpty()) + { + return MCPError(FString::Printf( + TEXT("Batch retarget created %d of %d requested assets and cleanup failed for: %s"), + Created.Num(), Loaded, *FString::Join(ResidualPaths, TEXT(", ")))); + } + return MCPError(FString::Printf( + TEXT("Batch retarget created %d of %d requested assets; all new outputs were deleted"), + Created.Num(), Loaded)); + } TArray> OutPaths; - for (const FAssetData& AD : Created) OutPaths.Add(MakeShared(AD.GetObjectPathString())); + for (const FAssetData& AD : Created) + { + UObject* CreatedAsset = AD.GetAsset(); + if (!CreatedAsset || !SaveAssetPackage(CreatedAsset)) + { + const FString FailedPath = AD.GetObjectPathString(); + const TArray ResidualPaths = DeleteCreatedAssets(); + if (!ResidualPaths.IsEmpty()) + { + return MCPError(FString::Printf( + TEXT("Failed to save retargeted asset '%s' and cleanup failed for: %s"), + *FailedPath, *FString::Join(ResidualPaths, TEXT(", ")))); + } + return MCPError(FString::Printf( + TEXT("Failed to save retargeted asset '%s'; all new outputs were deleted"), + *FailedPath)); + } + OutPaths.Add(MakeShared(AD.GetObjectPathString())); + } auto Result = MCPSuccess(); MCPSetCreated(Result); @@ -2010,6 +2518,25 @@ TSharedPtr FAnimationHandlers::BatchRetargetAnimations(const TShared Result->SetNumberField(TEXT("requested"), Loaded); Result->SetNumberField(TEXT("createdCount"), OutPaths.Num()); Result->SetArrayField(TEXT("createdAssets"), OutPaths); + Result->SetBoolField(TEXT("includedReferencedAssets"), false); + Result->SetBoolField(TEXT("requireCompleteMapping"), bRequireCompleteMapping); + Result->SetBoolField(TEXT("mappingInspectionAvailable"), bMappingInspectionAvailable); + if (bMappingInspectionAvailable) + { + Result->SetNumberField(TEXT("targetChainCount"), TargetChainCount); + Result->SetNumberField(TEXT("mappedChainCount"), MappedChainCount); + Result->SetArrayField(TEXT("unmappedTargetChains"), UnmappedTargetChains); + Result->SetBoolField(TEXT("mappingComplete"), UnmappedTargetChains.IsEmpty()); + if (!UnmappedTargetChains.IsEmpty()) + { + Result->SetStringField(TEXT("mappingWarning"), FString::Printf( + TEXT("Retarget completed with %d unmapped target chain(s); inspect deformation and compare sampled poses before accepting the output"), + UnmappedTargetChains.Num())); + } + } + TSharedPtr RollbackPayload = MakeShared(); + RollbackPayload->SetArrayField(TEXT("assetPaths"), OutPaths); + MCPSetRollback(Result, TEXT("delete_asset_batch"), RollbackPayload); return MCPResult(Result); #endif } diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp new file mode 100644 index 00000000..e9b97f86 --- /dev/null +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp @@ -0,0 +1,735 @@ +// Deterministic, data-first AnimSequence validation. +// +// Screenshots are useful review evidence, but exact pose samples are the +// source of truth for root motion, seams, bounds and numeric integrity. + +#include "AnimationHandlers.h" + +#include "HandlerUtils.h" + +#include "Animation/AnimCurveTypes.h" +#include "Animation/AnimData/IAnimationDataModel.h" +#include "Animation/AnimSequence.h" +#include "Animation/AnimationPoseData.h" +#include "Animation/AttributesRuntime.h" +#include "Animation/Skeleton.h" +#include "BoneContainer.h" +#include "BoneIndices.h" +#include "BonePose.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "Engine/SkeletalMesh.h" +#include "HAL/FileManager.h" +#include "Misc/AutomationTest.h" +#include "Misc/FileHelper.h" +#include "Misc/MemStack.h" +#include "Misc/Paths.h" +#include "ReferenceSkeleton.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" + +namespace +{ + TSharedPtr AnimQaVectorJson(const FVector& Value) + { + TSharedPtr Result = MakeShared(); + Result->SetNumberField(TEXT("x"), Value.X); + Result->SetNumberField(TEXT("y"), Value.Y); + Result->SetNumberField(TEXT("z"), Value.Z); + return Result; + } + + TSharedPtr AnimQaQuatJson(const FQuat& Value) + { + TSharedPtr Result = MakeShared(); + Result->SetNumberField(TEXT("x"), Value.X); + Result->SetNumberField(TEXT("y"), Value.Y); + Result->SetNumberField(TEXT("z"), Value.Z); + Result->SetNumberField(TEXT("w"), Value.W); + return Result; + } + + TSharedPtr AnimQaRotatorJson(const FRotator& Value) + { + TSharedPtr Result = MakeShared(); + Result->SetNumberField(TEXT("pitch"), Value.Pitch); + Result->SetNumberField(TEXT("yaw"), Value.Yaw); + Result->SetNumberField(TEXT("roll"), Value.Roll); + return Result; + } + + TSharedPtr AnimQaTransformJson(const FTransform& Value) + { + TSharedPtr Result = MakeShared(); + Result->SetObjectField(TEXT("translation"), AnimQaVectorJson(Value.GetTranslation())); + Result->SetObjectField(TEXT("rotation"), AnimQaQuatJson(Value.GetRotation())); + Result->SetObjectField(TEXT("rotationDegrees"), AnimQaRotatorJson(Value.Rotator())); + Result->SetObjectField(TEXT("scale"), AnimQaVectorJson(Value.GetScale3D())); + return Result; + } + + bool AnimQaRawTransformIsValid(const FTransform& Value) + { + const FVector Translation = Value.GetTranslation(); + const FVector Scale = Value.GetScale3D(); + const FQuat Rotation = Value.GetRotation(); + return !Translation.ContainsNaN() + && !Scale.ContainsNaN() + && !Rotation.ContainsNaN() + && FMath::IsFinite(Translation.X) && FMath::IsFinite(Translation.Y) && FMath::IsFinite(Translation.Z) + && FMath::IsFinite(Scale.X) && FMath::IsFinite(Scale.Y) && FMath::IsFinite(Scale.Z) + && FMath::IsFinite(Rotation.X) && FMath::IsFinite(Rotation.Y) + && FMath::IsFinite(Rotation.Z) && FMath::IsFinite(Rotation.W) + && !Scale.IsNearlyZero() + && Rotation.SizeSquared() > SMALL_NUMBER; + } + + bool AnimQaTransformIsFinite(const FTransform& Value) + { + return AnimQaRawTransformIsValid(Value) + && FMath::Abs(Value.GetRotation().SizeSquared() - 1.0) < 0.01; + } + + double AnimQaQuatAngleDegrees(const FQuat& A, const FQuat& B) + { + const double Dot = FMath::Clamp(FMath::Abs(static_cast(A | B)), 0.0, 1.0); + return FMath::RadiansToDegrees(2.0 * FMath::Acos(Dot)); + } + + bool AnimQaSerializeObject(const TSharedPtr& Object, FString& OutJson, bool bCondensed) + { + if (bCondensed) + { + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&OutJson); + return FJsonSerializer::Serialize(Object.ToSharedRef(), Writer); + } + + TSharedRef> Writer = TJsonWriterFactory<>::Create(&OutJson); + return FJsonSerializer::Serialize(Object.ToSharedRef(), Writer); + } + + bool AnimQaResolveOutputDirectory(const FString& Requested, FString& OutDirectory, FString& OutError) + { + FString NormalizedRoot = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("Codex"), TEXT("AnimationQA"))); + FPaths::NormalizeDirectoryName(NormalizedRoot); + + FString Candidate; + if (Requested.IsEmpty()) + { + OutDirectory.Reset(); + return true; + } + if (FPaths::IsRelative(Requested)) + { + FString ProjectRelativeCandidate = FPaths::ConvertRelativePathToFull( + FPaths::Combine(FPaths::ProjectDir(), Requested)); + FPaths::NormalizeDirectoryName(ProjectRelativeCandidate); + Candidate = FPaths::IsSamePath(ProjectRelativeCandidate, NormalizedRoot) + || FPaths::IsUnderDirectory(ProjectRelativeCandidate, NormalizedRoot) + ? ProjectRelativeCandidate + : FPaths::Combine(NormalizedRoot, Requested); + } + else + { + Candidate = Requested; + } + + Candidate = FPaths::ConvertRelativePathToFull(Candidate); + FPaths::NormalizeDirectoryName(Candidate); + if (!FPaths::IsSamePath(Candidate, NormalizedRoot) + && !FPaths::IsUnderDirectory(Candidate, NormalizedRoot)) + { + OutError = FString::Printf( + TEXT("outputDirectory must resolve under '%s'"), *NormalizedRoot); + return false; + } + + OutDirectory = Candidate; + return true; + } + + void AnimQaAddDefaultBoneIfPresent( + const FReferenceSkeleton& RefSkeleton, + const TCHAR* BoneName, + TArray& BoneIndices) + { + const int32 Index = RefSkeleton.FindBoneIndex(FName(BoneName)); + if (Index != INDEX_NONE) + { + BoneIndices.AddUnique(Index); + } + } +} + +#if WITH_DEV_AUTOMATION_TESTS + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FAnimationAnalysisOutputDirectoryNormalizationTest, + "UE.MCP.Animation.Analysis.OutputDirectoryNormalization", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FAnimationAnalysisOutputDirectoryNormalizationTest::RunTest(const FString& Parameters) +{ + const FString Leaf = TEXT("throw_production_v003_full"); + const FString QualifiedRequest = FPaths::Combine( + TEXT("Saved"), TEXT("Codex"), TEXT("AnimationQA"), Leaf); + FString RootRelativeOutput; + FString QualifiedOutput; + FString RootRelativeError; + FString QualifiedError; + TestTrue( + TEXT("a path relative to the AnimationQA root resolves"), + AnimQaResolveOutputDirectory(Leaf, RootRelativeOutput, RootRelativeError)); + TestTrue( + TEXT("a Project/Saved-qualified path resolves"), + AnimQaResolveOutputDirectory(QualifiedRequest, QualifiedOutput, QualifiedError)); + TestTrue( + TEXT("both forms resolve to the same directory exactly once"), + FPaths::IsSamePath(RootRelativeOutput, QualifiedOutput)); + return true; +} + +#endif + +TSharedPtr FAnimationHandlers::AnalyzeAnimation(const TSharedPtr& Params) +{ + FString AssetPath; + if (auto Error = RequireString(Params, TEXT("assetPath"), AssetPath)) return Error; + + UAnimSequence* Sequence = LoadObject(nullptr, *AssetPath); + if (!Sequence) + { + return MCPError(FString::Printf(TEXT("AnimSequence not found: %s"), *AssetPath)); + } + USkeleton* Skeleton = Sequence->GetSkeleton(); + if (!Skeleton) + { + return MCPError(TEXT("AnimSequence has no skeleton")); + } + if (Sequence->GetAdditiveAnimType() != AAT_None) + { + return MCPError(TEXT("Additive AnimSequences are not supported by analyze_animation v1; provide a baked full-pose sequence")); + } + const FString SkeletalMeshPath = OptionalString(Params, TEXT("skeletalMeshPath")); + USkeletalMesh* SkeletalMesh = nullptr; + if (!SkeletalMeshPath.IsEmpty()) + { + SkeletalMesh = LoadObject(nullptr, *SkeletalMeshPath); + if (!SkeletalMesh) + { + return MCPError(FString::Printf(TEXT("SkeletalMesh not found: %s"), *SkeletalMeshPath)); + } + if (!SkeletalMesh->GetSkeleton() + || !SkeletalMesh->GetSkeleton()->IsCompatibleForEditor(Skeleton)) + { + return MCPError(FString::Printf( + TEXT("SkeletalMesh '%s' is not compatible with animation skeleton '%s'"), + *SkeletalMeshPath, + *Skeleton->GetPathName())); + } + } + + const FReferenceSkeleton& RefSkeleton = SkeletalMesh + ? SkeletalMesh->GetRefSkeleton() + : Skeleton->GetReferenceSkeleton(); + const int32 BoneCount = RefSkeleton.GetNum(); + if (BoneCount <= 0) + { + return MCPError(TEXT("Skeleton has no bones")); + } + + const IAnimationDataModel* DataModel = Sequence->GetDataModel(); + if (!DataModel) + { + return MCPError(TEXT("AnimSequence has no animation data model")); + } + const FFrameRate SourceRate = DataModel->GetFrameRate(); + const double SourceRateDecimal = SourceRate.AsDecimal(); + if (!SourceRate.IsValid() || SourceRate.Numerator <= 0 || SourceRate.Denominator <= 0 + || !FMath::IsFinite(SourceRateDecimal) || SourceRateDecimal <= 0.0) + { + return MCPError(TEXT("AnimSequence has an invalid frame rate")); + } + const int32 SourceFrameCount = DataModel->GetNumberOfFrames(); + const double DurationSeconds = Sequence->GetPlayLength(); + if (SourceFrameCount < 1 || !FMath::IsFinite(DurationSeconds) || DurationSeconds <= 0.0) + { + return MCPError(TEXT("AnimSequence has an invalid duration or frame count")); + } + const double RateScale = static_cast(Sequence->RateScale); + if (!FMath::IsFinite(RateScale)) + { + return MCPError(TEXT("AnimSequence has an invalid RateScale")); + } + const double PlaybackRateMagnitude = FMath::Abs(RateScale); + const bool bHasEffectiveTiming = PlaybackRateMagnitude > 0.0; + const double EffectiveDurationSeconds = bHasEffectiveTiming + ? DurationSeconds / PlaybackRateMagnitude + : 0.0; + + TArray> NotifyValues; + NotifyValues.Reserve(Sequence->Notifies.Num()); + for (const FAnimNotifyEvent& NotifyEvent : Sequence->Notifies) + { + const double RawTriggerTimeSeconds = static_cast(NotifyEvent.GetTriggerTime()); + if (!FMath::IsFinite(RawTriggerTimeSeconds)) + { + return MCPError(TEXT("AnimSequence has a notify with an invalid trigger time")); + } + + TSharedPtr NotifyObject = MakeShared(); + NotifyObject->SetStringField(TEXT("name"), NotifyEvent.NotifyName.ToString()); + NotifyObject->SetNumberField(TEXT("rawTriggerTimeSeconds"), RawTriggerTimeSeconds); + if (bHasEffectiveTiming) + { + NotifyObject->SetNumberField( + TEXT("effectiveTriggerTimeSeconds"), + RawTriggerTimeSeconds / PlaybackRateMagnitude); + } + else + { + NotifyObject->SetField(TEXT("effectiveTriggerTimeSeconds"), MakeShared()); + } + NotifyValues.Add(MakeShared(NotifyObject)); + } + + TArray BoneIndices; + const TArray>* BoneNamesJson = nullptr; + if (Params->TryGetArrayField(TEXT("boneNames"), BoneNamesJson)) + { + for (const TSharedPtr& Value : *BoneNamesJson) + { + FString BoneName; + if (!Value.IsValid() || !Value->TryGetString(BoneName)) + { + return MCPError(TEXT("boneNames must contain only strings")); + } + const int32 BoneIndex = RefSkeleton.FindBoneIndex(FName(*BoneName)); + if (BoneIndex == INDEX_NONE) + { + return MCPError(FString::Printf(TEXT("Bone not found on skeleton: %s"), *BoneName)); + } + BoneIndices.AddUnique(BoneIndex); + } + } + else + { + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("root"), BoneIndices); + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("pelvis"), BoneIndices); + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("head"), BoneIndices); + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("hand_l"), BoneIndices); + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("hand_r"), BoneIndices); + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("foot_l"), BoneIndices); + AnimQaAddDefaultBoneIfPresent(RefSkeleton, TEXT("foot_r"), BoneIndices); + if (BoneIndices.IsEmpty()) + { + for (int32 Index = 0; Index < FMath::Min(BoneCount, 16); ++Index) + { + BoneIndices.Add(Index); + } + } + } + if (BoneIndices.IsEmpty()) + { + return MCPError(TEXT("No bones selected for analysis")); + } + if (BoneIndices.Num() > 256) + { + return MCPError(TEXT("analyze_animation supports at most 256 selected bones per call")); + } + + const bool bLoop = OptionalBool(Params, TEXT("loop"), false); + TArray Frames; + const TArray>* FramesJson = nullptr; + if (Params->TryGetArrayField(TEXT("frames"), FramesJson)) + { + if (FramesJson->Num() > 2401) + { + return MCPError(TEXT("analyze_animation supports at most 2401 explicit frames per call")); + } + for (const TSharedPtr& Value : *FramesJson) + { + double Number = 0.0; + if (!Value.IsValid() || !Value->TryGetNumber(Number) || !FMath::IsFinite(Number) + || Number < 0.0 || Number > SourceFrameCount + || !FMath::IsNearlyEqual(Number, FMath::RoundToDouble(Number))) + { + return MCPError(FString::Printf( + TEXT("frames must contain only integers in [0, %d]"), SourceFrameCount)); + } + Frames.AddUnique(FMath::RoundToInt(Number)); + } + } + else + { + double RequestedRate = SourceRateDecimal; + if (Params->HasField(TEXT("sampleRate")) + && (!Params->TryGetNumberField(TEXT("sampleRate"), RequestedRate) + || !FMath::IsFinite(RequestedRate) || RequestedRate < 1.0 || RequestedRate > 240.0)) + { + return MCPError(TEXT("'sampleRate' must be a finite number in [1, 240]")); + } + const int32 SampleCount = FMath::Clamp( + FMath::CeilToInt(DurationSeconds * RequestedRate) + 1, + 2, + 2401); + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + const double Alpha = SampleCount > 1 + ? static_cast(SampleIndex) / static_cast(SampleCount - 1) + : 0.0; + Frames.AddUnique(FMath::Clamp(FMath::RoundToInt(Alpha * SourceFrameCount), 0, SourceFrameCount)); + } + } + if (Frames.IsEmpty()) + { + return MCPError(TEXT("No frames selected for analysis")); + } + if (bLoop) + { + Frames.AddUnique(0); + Frames.AddUnique(SourceFrameCount); + } + Frames.Sort(); + if (static_cast(Frames.Num()) * static_cast(BoneIndices.Num()) > 100000) + { + return MCPError(TEXT("analyze_animation supports at most 100000 bone-frame samples per call")); + } + + TArray RequiredBoneIndices; + RequiredBoneIndices.Reserve(BoneCount); + for (int32 Index = 0; Index < BoneCount; ++Index) + { + RequiredBoneIndices.Add(static_cast(Index)); + } + FBoneContainer RequiredBones; + RequiredBones.InitializeTo( + RequiredBoneIndices, + UE::Anim::FCurveFilterSettings(UE::Anim::ECurveFilterMode::DisallowAll), + SkeletalMesh ? static_cast(*SkeletalMesh) : static_cast(*Skeleton)); + const auto CompactFromReferenceIndex = [&RequiredBones, SkeletalMesh](int32 Index) + { + return SkeletalMesh + ? RequiredBones.MakeCompactPoseIndex(FMeshPoseBoneIndex(Index)) + : RequiredBones.GetCompactPoseIndexFromSkeletonPoseIndex(FSkeletonPoseBoneIndex(Index)); + }; + const auto ReferenceIndexFromCompact = [&RequiredBones, SkeletalMesh](FCompactPoseBoneIndex Index) + { + return SkeletalMesh + ? RequiredBones.MakeMeshPoseIndex(Index).GetInt() + : RequiredBones.GetSkeletonPoseIndexFromCompactPoseIndex(Index).GetInt(); + }; + + bool bNumericIntegrity = true; + int32 InvalidTransformCount = 0; + FVector BoundsMin(UE_BIG_NUMBER, UE_BIG_NUMBER, UE_BIG_NUMBER); + FVector BoundsMax(-UE_BIG_NUMBER, -UE_BIG_NUMBER, -UE_BIG_NUMBER); + FVector FirstRoot = FVector::ZeroVector; + FVector LastRoot = FVector::ZeroVector; + FVector PreviousRoot = FVector::ZeroVector; + double PreviousTime = 0.0; + double MaxRootSpeed = 0.0; + bool bHasPreviousRoot = false; + TArray FirstLocalTransforms; + TArray LastLocalTransforms; + TArray> SampleValues; + TArray SampleLines; + + for (int32 Frame : Frames) + { + FMemMark FrameMark(FMemStack::Get()); + const double TimeSeconds = FMath::Clamp( + static_cast(Frame) / SourceRateDecimal, + 0.0, + DurationSeconds); + + FCompactPose CompactPose; + CompactPose.SetBoneContainer(&RequiredBones); + CompactPose.ResetToRefPose(); + FBlendedCurve Curve; + Curve.InitFrom(RequiredBones); + UE::Anim::FStackAttributeContainer Attributes; + FAnimationPoseData PoseData(CompactPose, Curve, Attributes); + FAnimExtractContext ExtractContext(TimeSeconds, false); +#if WITH_EDITOR + ExtractContext.bIgnoreRootLock = true; +#endif + Sequence->GetAnimationPose(PoseData, ExtractContext); + for (const FCompactPoseBoneIndex BoneIndex : CompactPose.ForEachBoneIndex()) + { + if (!AnimQaRawTransformIsValid(CompactPose[BoneIndex])) + { + const int32 ReferenceBoneIndex = ReferenceIndexFromCompact(BoneIndex); + return MCPError(FString::Printf( + TEXT("AnimSequence produced an invalid raw transform at frame %d for bone %s"), + Frame, + *RefSkeleton.GetBoneName(ReferenceBoneIndex).ToString())); + } + } + CompactPose.NormalizeRotations(); + + FCSPose ComponentPose; + ComponentPose.InitPose(CompactPose); + + TSharedPtr SampleObject = MakeShared(); + SampleObject->SetNumberField(TEXT("frame"), Frame); + SampleObject->SetNumberField(TEXT("timeSeconds"), TimeSeconds); + SampleObject->SetStringField(TEXT("timeRational"), FString::Printf( + TEXT("%lld/%d"), + static_cast(Frame) * static_cast(SourceRate.Denominator), + SourceRate.Numerator)); + + TArray> BoneValues; + TArray CurrentLocalTransforms; + CurrentLocalTransforms.Reserve(BoneIndices.Num()); + for (int32 ReferenceBoneIndex : BoneIndices) + { + const FCompactPoseBoneIndex CompactIndex = CompactFromReferenceIndex(ReferenceBoneIndex); + if (CompactIndex == INDEX_NONE) + { + return MCPError(FString::Printf( + TEXT("Bone %s could not be mapped into the evaluated compact pose"), + *RefSkeleton.GetBoneName(ReferenceBoneIndex).ToString())); + } + + const FTransform LocalTransform = CompactPose[CompactIndex]; + const FTransform ComponentTransform = ComponentPose.GetComponentSpaceTransform(CompactIndex); + CurrentLocalTransforms.Add(LocalTransform); + if (!AnimQaTransformIsFinite(LocalTransform) || !AnimQaTransformIsFinite(ComponentTransform)) + { + return MCPError(FString::Printf( + TEXT("AnimSequence produced an invalid evaluated transform at frame %d for bone %s"), + Frame, + *RefSkeleton.GetBoneName(ReferenceBoneIndex).ToString())); + } + + const FVector Position = ComponentTransform.GetTranslation(); + BoundsMin.X = FMath::Min(BoundsMin.X, Position.X); + BoundsMin.Y = FMath::Min(BoundsMin.Y, Position.Y); + BoundsMin.Z = FMath::Min(BoundsMin.Z, Position.Z); + BoundsMax.X = FMath::Max(BoundsMax.X, Position.X); + BoundsMax.Y = FMath::Max(BoundsMax.Y, Position.Y); + BoundsMax.Z = FMath::Max(BoundsMax.Z, Position.Z); + + TSharedPtr BoneObject = MakeShared(); + BoneObject->SetStringField(TEXT("name"), RefSkeleton.GetBoneName(ReferenceBoneIndex).ToString()); + BoneObject->SetNumberField(TEXT("index"), ReferenceBoneIndex); + BoneObject->SetNumberField(TEXT("parentIndex"), RefSkeleton.GetParentIndex(ReferenceBoneIndex)); + BoneObject->SetObjectField(TEXT("local"), AnimQaTransformJson(LocalTransform)); + BoneObject->SetObjectField(TEXT("component"), AnimQaTransformJson(ComponentTransform)); + BoneValues.Add(MakeShared(BoneObject)); + } + SampleObject->SetArrayField(TEXT("bones"), BoneValues); + + const FCompactPoseBoneIndex RootIndex = CompactFromReferenceIndex(0); + const FTransform RootTransform = ComponentPose.GetComponentSpaceTransform(RootIndex); + SampleObject->SetObjectField(TEXT("rootComponent"), AnimQaTransformJson(RootTransform)); + const FVector RootPosition = RootTransform.GetTranslation(); + if (!bHasPreviousRoot) + { + FirstRoot = RootPosition; + FirstLocalTransforms = CurrentLocalTransforms; + } + else + { + const double DeltaSeconds = TimeSeconds - PreviousTime; + if (DeltaSeconds > SMALL_NUMBER) + { + MaxRootSpeed = FMath::Max( + MaxRootSpeed, + static_cast(FVector::Distance(RootPosition, PreviousRoot)) / DeltaSeconds); + } + } + bHasPreviousRoot = true; + PreviousRoot = RootPosition; + PreviousTime = TimeSeconds; + LastRoot = RootPosition; + LastLocalTransforms = CurrentLocalTransforms; + + SampleValues.Add(MakeShared(SampleObject)); + FString SampleJson; + if (!AnimQaSerializeObject(SampleObject, SampleJson, true)) + { + return MCPError(FString::Printf(TEXT("Failed to serialize animation sample at frame %d"), Frame)); + } + SampleLines.Add(MoveTemp(SampleJson)); + } + + double LoopMaxAngle = 0.0; + double LoopAngleSquaredSum = 0.0; + int32 LoopAngleCount = 0; + const bool bHasRootMotion = Sequence->HasRootMotion(); + if (bLoop && FirstLocalTransforms.Num() == LastLocalTransforms.Num()) + { + for (int32 Index = 0; Index < FirstLocalTransforms.Num(); ++Index) + { + if (bHasRootMotion && BoneIndices[Index] == 0) + { + continue; + } + const double Angle = AnimQaQuatAngleDegrees( + FirstLocalTransforms[Index].GetRotation(), + LastLocalTransforms[Index].GetRotation()); + LoopMaxAngle = FMath::Max(LoopMaxAngle, Angle); + LoopAngleSquaredSum += Angle * Angle; + ++LoopAngleCount; + } + } + const double LoopRmsAngle = LoopAngleCount > 0 + ? FMath::Sqrt(LoopAngleSquaredSum / static_cast(LoopAngleCount)) + : 0.0; + const double RootDisplacement = FVector::Distance(FirstRoot, LastRoot); + const double LoopRootTranslationError = bHasRootMotion ? 0.0 : RootDisplacement; + FAnimExtractContext RootMotionContext; +#if WITH_EDITOR + RootMotionContext.bIgnoreRootLock = true; +#endif +#if ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 6) + const FTransform CycleRootMotion = Sequence->ExtractRootMotionFromRange(0.0, DurationSeconds, RootMotionContext); +#else + const FTransform CycleRootMotion = Sequence->ExtractRootMotionFromRange(0.0, DurationSeconds); +#endif + const bool bLoopWarn = bLoop && (LoopRootTranslationError > 1.0 || LoopMaxAngle > 5.0); + + TSharedPtr Summary = MakeShared(); + Summary->SetStringField(TEXT("status"), !bNumericIntegrity ? TEXT("fail") : (bLoopWarn ? TEXT("warn") : TEXT("pass"))); + Summary->SetStringField(TEXT("confidence"), TEXT("high")); + Summary->SetBoolField(TEXT("numericIntegrity"), bNumericIntegrity); + Summary->SetNumberField(TEXT("invalidTransformCount"), InvalidTransformCount); + Summary->SetBoolField(TEXT("hasRootMotion"), bHasRootMotion); + Summary->SetNumberField(TEXT("rootDisplacementCm"), RootDisplacement); + Summary->SetNumberField(TEXT("maxRootSpeedCmPerSecond"), MaxRootSpeed); + Summary->SetObjectField(TEXT("boundsMinCm"), AnimQaVectorJson(BoundsMin)); + Summary->SetObjectField(TEXT("boundsMaxCm"), AnimQaVectorJson(BoundsMax)); + TSharedPtr LoopSummary = MakeShared(); + LoopSummary->SetBoolField(TEXT("evaluated"), bLoop); + LoopSummary->SetBoolField(TEXT("rootExcludedFromPoseSeam"), bHasRootMotion); + LoopSummary->SetNumberField(TEXT("rootTranslationErrorCm"), bLoop ? LoopRootTranslationError : 0.0); + LoopSummary->SetNumberField(TEXT("jointAngleRmsDegrees"), bLoop ? LoopRmsAngle : 0.0); + LoopSummary->SetNumberField(TEXT("jointAngleMaxDegrees"), bLoop ? LoopMaxAngle : 0.0); + LoopSummary->SetObjectField(TEXT("cycleRootMotion"), AnimQaTransformJson(CycleRootMotion)); + Summary->SetObjectField(TEXT("loopSeam"), LoopSummary); + + FString OutputDirectory; + FString OutputError; + if (!AnimQaResolveOutputDirectory(OptionalString(Params, TEXT("outputDirectory")), OutputDirectory, OutputError)) + { + return MCPError(OutputError); + } + + TSharedPtr Manifest = MakeShared(); + Manifest->SetStringField(TEXT("schema"), TEXT("ue-mcp://animation-validation/v1")); + Manifest->SetStringField(TEXT("assetPath"), AssetPath); + Manifest->SetStringField(TEXT("skeletonPath"), Skeleton->GetPathName()); + if (!SkeletalMeshPath.IsEmpty()) Manifest->SetStringField(TEXT("skeletalMeshPath"), SkeletalMeshPath); + Manifest->SetNumberField(TEXT("durationSeconds"), DurationSeconds); + Manifest->SetNumberField(TEXT("rateScale"), RateScale); + if (bHasEffectiveTiming) + { + Manifest->SetNumberField(TEXT("effectiveDurationSeconds"), EffectiveDurationSeconds); + } + else + { + Manifest->SetField(TEXT("effectiveDurationSeconds"), MakeShared()); + } + Manifest->SetArrayField(TEXT("notifies"), NotifyValues); + TSharedPtr FrameRateObject = MakeShared(); + FrameRateObject->SetNumberField(TEXT("numerator"), SourceRate.Numerator); + FrameRateObject->SetNumberField(TEXT("denominator"), SourceRate.Denominator); + Manifest->SetObjectField(TEXT("displayRate"), FrameRateObject); + Manifest->SetNumberField(TEXT("sourceFrameCount"), SourceFrameCount); + Manifest->SetNumberField(TEXT("sampleCount"), Frames.Num()); + Manifest->SetBoolField(TEXT("loop"), bLoop); + Manifest->SetStringField(TEXT("units"), TEXT("centimeters")); + Manifest->SetStringField(TEXT("handedness"), TEXT("left")); + Manifest->SetStringField(TEXT("forward"), TEXT("+X")); + Manifest->SetStringField(TEXT("right"), TEXT("+Y")); + Manifest->SetStringField(TEXT("up"), TEXT("+Z")); + Manifest->SetObjectField(TEXT("summary"), Summary); + Manifest->SetStringField(TEXT("samplesFile"), TEXT("samples.ndjson")); + + FString ManifestPath; + FString SamplesPath; + if (!OutputDirectory.IsEmpty()) + { + if (!IFileManager::Get().MakeDirectory(*OutputDirectory, true)) + { + return MCPError(FString::Printf(TEXT("Failed to create output directory: %s"), *OutputDirectory)); + } + ManifestPath = FPaths::Combine(OutputDirectory, TEXT("manifest.json")); + SamplesPath = FPaths::Combine(OutputDirectory, TEXT("samples.ndjson")); + if (IFileManager::Get().FileExists(*ManifestPath) || IFileManager::Get().FileExists(*SamplesPath)) + { + return MCPError(FString::Printf( + TEXT("outputDirectory already contains animation validation artifacts: %s"), + *OutputDirectory)); + } + const FString ManifestTempPath = ManifestPath + TEXT(".tmp"); + const FString SamplesTempPath = SamplesPath + TEXT(".tmp"); + IFileManager::Get().Delete(*ManifestTempPath, false, true); + IFileManager::Get().Delete(*SamplesTempPath, false, true); + FString ManifestJson; + if (!AnimQaSerializeObject(Manifest, ManifestJson, false)) + { + return MCPError(TEXT("Failed to serialize animation validation manifest")); + } + const FString SamplesJson = FString::Join(SampleLines, TEXT("\n")) + TEXT("\n"); + if (!FFileHelper::SaveStringToFile( + SamplesJson, + *SamplesTempPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + return MCPError(FString::Printf(TEXT("Failed to write samples: %s"), *SamplesPath)); + } + if (!FFileHelper::SaveStringToFile( + ManifestJson, + *ManifestTempPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + IFileManager::Get().Delete(*SamplesTempPath, false, true); + return MCPError(FString::Printf(TEXT("Failed to write manifest: %s"), *ManifestPath)); + } + if (!IFileManager::Get().Move(*SamplesPath, *SamplesTempPath, false, true)) + { + IFileManager::Get().Delete(*ManifestTempPath, false, true); + IFileManager::Get().Delete(*SamplesTempPath, false, true); + return MCPError(FString::Printf(TEXT("Failed to commit samples: %s"), *SamplesPath)); + } + if (!IFileManager::Get().Move(*ManifestPath, *ManifestTempPath, false, true)) + { + IFileManager::Get().Delete(*ManifestTempPath, false, true); + IFileManager::Get().Delete(*SamplesPath, false, true); + return MCPError(FString::Printf(TEXT("Failed to commit manifest: %s"), *ManifestPath)); + } + } + + TSharedPtr Result = MCPSuccess(); + Result->SetStringField(TEXT("assetPath"), AssetPath); + Result->SetStringField(TEXT("skeletonPath"), Skeleton->GetPathName()); + if (!SkeletalMeshPath.IsEmpty()) Result->SetStringField(TEXT("skeletalMeshPath"), SkeletalMeshPath); + Result->SetNumberField(TEXT("durationSeconds"), DurationSeconds); + Result->SetNumberField(TEXT("rateScale"), RateScale); + if (bHasEffectiveTiming) + { + Result->SetNumberField(TEXT("effectiveDurationSeconds"), EffectiveDurationSeconds); + } + else + { + Result->SetField(TEXT("effectiveDurationSeconds"), MakeShared()); + } + Result->SetArrayField(TEXT("notifies"), NotifyValues); + Result->SetNumberField(TEXT("sourceFrameCount"), SourceFrameCount); + Result->SetNumberField(TEXT("sampleCount"), Frames.Num()); + Result->SetObjectField(TEXT("displayRate"), FrameRateObject); + Result->SetObjectField(TEXT("summary"), Summary); + Result->SetArrayField(TEXT("samples"), SampleValues); + if (!OutputDirectory.IsEmpty()) + { + Result->SetStringField(TEXT("outputDirectory"), OutputDirectory); + Result->SetStringField(TEXT("manifestPath"), ManifestPath); + Result->SetStringField(TEXT("samplesPath"), SamplesPath); + } + return MCPResult(Result); +} diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp index c6f70b9b..64499bbe 100644 --- a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp @@ -28,6 +28,7 @@ #include "Framework/Docking/TabManager.h" #include "Widgets/Docking/SDockTab.h" #include "ISettingsModule.h" +#include "Interfaces/IMainFrameModule.h" #include "Modules/ModuleManager.h" #include "Misc/ConfigCacheIni.h" #include "Misc/ConfigContext.h" @@ -1956,7 +1957,8 @@ TSharedPtr FEditorHandlers::RequestEditorShutdown(const TSharedPtr(TEXT("MainFrame")); + MainFrameModule.RequestCloseEditor(); return false; }), 1.0f); diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp new file mode 100644 index 00000000..05b5599b --- /dev/null +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp @@ -0,0 +1,60 @@ +#if WITH_DEV_AUTOMATION_TESTS && (ENGINE_MAJOR_VERSION > 5 || (ENGINE_MAJOR_VERSION == 5 && ENGINE_MINOR_VERSION >= 8)) + +#include "Animation/AnimData/IAnimationDataController.h" +#include "Animation/AnimSequence.h" +#include "Animation/Skeleton.h" +#include "Misc/AutomationTest.h" +#include "ReferenceSkeleton.h" +#include "Sections/MovieSceneSkeletalAnimationSection.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FAnimationControlRigRawTimelineRateScaleTest, + "UE.MCP.Animation.ControlRig.RawTimelineRateScale", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FAnimationControlRigRawTimelineRateScaleTest::RunTest(const FString& Parameters) +{ + const FFrameRate DisplayRate(25, 1); + USkeleton* Skeleton = NewObject(GetTransientPackage()); + { + FReferenceSkeletonModifier Modifier(Skeleton); + Modifier.Add(FMeshBoneInfo(TEXT("root"), TEXT("root"), INDEX_NONE), FTransform::Identity); + } + UAnimSequence* SourceAnimation = NewObject(GetTransientPackage()); + SourceAnimation->SetSkeleton(Skeleton); + IAnimationDataController& Controller = SourceAnimation->GetController(); + Controller.InitializeModel(); + Controller.OpenBracket(FText::FromString(TEXT("Build raw-timeline test animation")), false); + Controller.SetFrameRate(DisplayRate, false); + Controller.SetNumberOfFrames(FFrameNumber(40), false); + Controller.CloseBracket(false); + SourceAnimation->RateScale = 3.06608796f; + + UMovieSceneSkeletalAnimationSection* Section = + NewObject(GetTransientPackage()); + Section->Params.Animation = SourceAnimation; + Section->Params.PlayRate = 1.0f / SourceAnimation->RateScale; + Section->SetRange(TRange(FFrameNumber(0), FFrameNumber(41))); + + double PreviousTime = -1.0; + for (int32 Frame = 0; Frame <= 40; ++Frame) + { + const double ExpectedTime = static_cast(Frame) / DisplayRate.AsDecimal(); + const double MappedTime = Section->MapTimeToAnimation(FFrameTime(Frame), DisplayRate); + TestTrue( + *FString::Printf(TEXT("frame %d maps to raw source time"), Frame), + FMath::IsNearlyEqual(MappedTime, ExpectedTime, 0.0001)); + TestTrue( + *FString::Printf(TEXT("frame %d does not loop backwards"), Frame), + MappedTime >= PreviousTime); + PreviousTime = MappedTime; + } + + TestTrue( + TEXT("the final frame reaches the source end once"), + FMath::IsNearlyEqual(PreviousTime, SourceAnimation->GetPlayLength(), 0.0001)); + TestEqual(TEXT("the source RateScale is unchanged"), SourceAnimation->RateScale, 3.06608796f); + return true; +} + +#endif diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h index bfc143a3..25e5fd9e 100644 --- a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h @@ -153,15 +153,17 @@ inline bool MCPIsProtectedAssetPath(const FString& Path) FString Normalized = Path; Normalized.TrimStartAndEndInline(); if (Normalized.IsEmpty()) return false; + Normalized = FPackageName::ExportTextPathToObjectPath(Normalized); + Normalized.TrimStartAndEndInline(); // Tolerate the surface form, which may arrive without a leading slash. if (!Normalized.StartsWith(TEXT("/"))) Normalized = TEXT("/") + Normalized; const FString Lower = Normalized.ToLower(); - if (Lower.StartsWith(TEXT("/engine/"))) return true; - if (Lower.StartsWith(TEXT("/memory/"))) return true; - if (Lower.StartsWith(TEXT("/temp/"))) return true; + if (Lower == TEXT("/engine") || Lower.StartsWith(TEXT("/engine/"))) return true; + if (Lower == TEXT("/memory") || Lower.StartsWith(TEXT("/memory/"))) return true; + if (Lower == TEXT("/temp") || Lower.StartsWith(TEXT("/temp/"))) return true; // Verse runtime objects surface as /Script/CoreUObject.* etc, so /Script/ // is rejected wherever it appears, not just as a prefix. - if (Lower.Contains(TEXT("/script/"))) return true; + if (Lower == TEXT("/script") || Lower.Contains(TEXT("/script/"))) return true; return false; } diff --git a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs index 5b0a5bd9..fbd61b59 100644 --- a/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs +++ b/plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs @@ -10,6 +10,11 @@ public class UE_MCP_Bridge : ModuleRules // file list and will not pick up a new .cpp until this file changes. // Private/BridgeStateFiles.cpp, Private/BridgeParamEcho.cpp and // Private/Tests/BridgeProtocolTests.cpp: same reason. + // Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp, + // Private/Handlers/AnimationHandlers_Validation.cpp, + // Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp and + // Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp plus + // Private/Tests/AnimationControlRigTimelineTests.cpp: same reason. public UE_MCP_Bridge(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; @@ -31,8 +36,10 @@ public UE_MCP_Bridge(ReadOnlyTargetRules Target) : base(Target) { "AIModule", "MessageLog", + "AnimationCore", "AnimGraph", "AnimationEditor", + "AnimationBlueprintLibrary", "AnimationModifiers", "AssetRegistry", "AssetTools", @@ -51,6 +58,7 @@ public UE_MCP_Bridge(ReadOnlyTargetRules Target) : base(Target) "ContentBrowser", "ControlRig", "ControlRigDeveloper", + "ControlRigEditor", "RigVMDeveloper", "DataValidation", "EditorScriptingUtilities", @@ -74,6 +82,7 @@ public UE_MCP_Bridge(ReadOnlyTargetRules Target) : base(Target) "LevelEditor", "LevelSequence", "LevelSequenceEditor", + "MainFrame", "MaterialEditor", "MovieScene", "MovieSceneTracks", diff --git a/skills/ue-mcp-animation/SKILL.md b/skills/ue-mcp-animation/SKILL.md new file mode 100644 index 00000000..7649d27e --- /dev/null +++ b/skills/ue-mcp-animation/SKILL.md @@ -0,0 +1,154 @@ +--- +name: ue-mcp-animation +description: Use when creating, modifying, retargeting, rigging, constraining, or validating skeletal animation through UE-MCP. Covers native UE 5.8 IK Rig and Retargeter authoring, the Control Rig begin/read/apply/bake loop, generic contact locks, per-rig anatomical and mirrored-axis discovery, quaternion keying, deterministic bone analysis, and exact fixed-frame Unreal capture. +--- + +# UE-MCP native animation workflow + +Create quality animation from measured pose constraints, not guessed control +angles. Control names, local axes, palm axes, handedness, and scale are +properties of the selected rig. Discover them again for every unfamiliar rig; +never paste values from another rig or negate a left-side Euler pose to make a +right-side pose. + +## Required loop + +1. Call `project(action="get_status")`; verify the intended project and editor. +2. Establish the character's authoring baseline before editing clips. Search + for a Control Rig already bound to the target mesh/skeleton and inspect it + with `read_control_rig_hierarchy` and `read_control_rig_graph`. It must have + the controls, Forward Solve, and Backward Solve required for the intended + edits. If the project or character has no suitable rig, create one first + with the bundled Epic 5.8 controlrig actions (`epic_create`, + `epic_import_bones_from_asset`, `epic_add_control`, deliberate Forward Solve + nodes/links, and `epic_add_backward_solve_graph`), then save the exact rig + with `asset(epic_save_assets)`. `epic_create` alone creates no imported + bones, authored controls, or solver wiring. Run an unchanged + source-to-controls-to-bones round trip on the exact production mesh. Do not + begin production work on an unverified or merely name-compatible rig. +3. Resolve the source AnimSequence, mesh, skeleton, verified Control Rig, and + frame rate. + When IK/retarget assets are part of the job, use `read_ik_rig` and + `read_ik_retargeter` first. On UE 5.8 use `configure_ik_rig` for validated + roots, ancestry-valid chains, concrete goals, solver connections and + effectors; use `configure_ik_retargeter` for the default op stack, source and + target rig assignment to every op, auto/manual mappings, preview meshes, and + a named retarget pose. Read both assets back after saving. A chain's goal + string is not proof that a goal or solver exists. +4. Create a versioned session with `begin_control_rig_edit`. Use a unique + LevelSequence path and `bindingTag`, an end-exclusive frame range, and + `onConflict="error"`. Use `rigMode="asset"` for a project rig or `"fk"` only + when generated FK controls are intentional. The source must be non-additive + and mesh-compatible; flatten an additive clip against its intended base + first. `layered` controls the session layer, not an additive source base. + A finite non-zero source `RateScale` is compensated in Sequencer so the raw + timeline maps once without modifying the source asset; zero is rejected. +5. Call `read_control_rig_edit` at rest, transitions, extrema, and end in both + `local` and `global` space. Here `global` is rig/global (normally mesh + component) space, not actor world space. +6. Inspect every control's `controlType`, `animatable`, and enum metadata. Write + scalars with the matching `set_bool`, `set_float`, or `set_int`; enum values + must come from `enumOptions`. Never write `animatable=false` controls. +7. Define anatomical component-space targets, then solve proximal to distal: + shoulder/upper arm, elbow pole and bend, forearm direction, wrist, palm + normal, then secondary motion. For a wave, the forearm must rise, the wrist + must sit above the elbow/near the shoulder region, and the probed palm normal + must face the intended viewer before wrist oscillation is added. +8. When an axis is uncertain, make an immutable probe session. Apply a small + positive and negative rotation to one local axis at one fixed frame, bake, + and inspect the resulting component-space shoulder/forearm/hand landmarks + with `analyze_animation`. Probe the right side separately; mirrored parents + or negative scale can reverse anatomical meanings. Preserve the full scale + read from the control. +9. Apply absolute `set_keys` transforms with finite normalized quaternions, + complete translation/rotationQuaternion/scale payloads, and strictly + increasing frames. Preserve translation and scale unless intentionally + editing them. If the source bake has dense keys, key every affected frame; + sparse keys will not replace the intervening source motion. Apply related + controls and scalar switches in one transaction. + For a fixed contact, use `contact_lock` in the same operation batch. Supply a + keyable translatable driver, optional driven bone/socket, inclusive frame + range, component-space target, optional pole/stabilizer controls, and + position/rotation tolerances. The session must contain one source animation + section. The bridge samples it per frame, writes dense smooth-edged keys, and + transactionally reads back the driver and stabilizers. A driven bone/socket + returns `verification=bake_and_analyze_required`; bake, analyze every + constrained frame, and reject the output if its residual misses the motion's + acceptance tolerance. FK contacts whose translation is ignored by skeleton + retargeting use a local rotation-chain solve and report + `solver=fk_rotation_chain`; that path requires a driven bone and does not + accept stabilizers. Position-only locks leave the driven control orientation + unkeyed. The bridge does not guess the driver, pole, foot roll, friction, + joint limits, or pelvis compensation. +10. Read back the edited frames in local and global space. Reject elbow flips, + discontinuities, wrong forearm direction, wrong palm normal, or unexpected + changes outside the edited chain before baking. +11. Bake to a new versioned AnimSequence with `bake_control_rig_edit`, + `reduceKeys=false`, and `onConflict="error"`. Never overwrite source, + another iteration's session, or prior approved output assets. + +## Validation and visual review + +Run `analyze_animation` on source and output using the same mesh, explicit +frames, and bones. Include root, pelvis, the complete edited chain, feet, +opposite side, and any controls/bones expected to remain unchanged. Write its +native `manifest.json` and `samples.ndjson` beneath +`Saved/Codex/AnimationQA`. + +Check numeric integrity, invalid transforms, selected-bone bounds, root +displacement/speed, and loop seam metrics. Derive gesture-specific checks from +component transforms: shoulder-relative wrist height, elbow-to-wrist vector, +elbow angle/plane stability, probed palm-normal alignment, speed/acceleration, +direction changes, and drift in untouched bones. Numeric samples are the source +of truth; screenshots are the human visual gate. + +Use the analyzer's `rateScale`, `effectiveDurationSeconds`, and per-notify +`rawTriggerTimeSeconds` / `effectiveTriggerTimeSeconds` fields when validating +gameplay release timing. Effective times use the asset-rate magnitude and are +null when the asset rate is zero. + +For an exact native frame capture, without Computer Use or Python: + +1. `editor(action="open_asset", assetPath=)`. +2. `editor(action="find_object")` for `className="AnimSingleNodeInstance"`, + `nameContains="AnimPreviewInstance"`, `world="any"`; select the match under + the current `AnimationEditorPreviewActor`. +3. In one `editor(action="invoke_object_functions")` call, invoke `SetPlaying` with + `bIsPlaying=false`, then `SetPosition` with + `InPosition=frame*rateDenominator/rateNumerator` and + `bFireNotifies=false` on that object path. +4. Open the asset again to focus its window and call + `editor(action="capture_screenshot", target="window")`. +5. Capture start, entry, both extrema, exit, and end with a consistent view. + +Keep versioned V&V fixtures for a from-scratch gesture, full-body IK authoring, +a retarget between known skeletons, a copied animation modified through IK plus +its pole target, a bone/socket contact with an unrelated simultaneous edit, and +edge cases covering mirrored/negative scale, dense keys, additive-source +rejection/flattening, layered sessions, scalar enums, root motion, loops, and +short clips. + +The loop generalizes beyond humanoid arms. Re-discover the rig mapping, then +express legs as hip/knee-pole/foot/contact constraints; spine and head as +arc/twist/aim constraints; tails, tentacles, and ropes as length-preserving +chain curves with delayed phase; and props or mechanisms as pivot, attachment, +contact, and clearance constraints. Only control/bone names, axes/signs, +mirrored scale, limits, and motion constraints are rig-specific. + +## Compatibility and endpoint rule + +The four Control Rig session actions, `configure_ik_rig`, +`configure_ik_retargeter`, and `contact_lock` are UE 5.8 only and return +`unsupported_engine_version` on older engines; do not invent a reflected or +raw-bone fallback. Legacy IK create/read behavior is unchanged. +`analyze_animation` is cross-version through the native APIs in the compiled +engine. See the public +[Native Control Rig Animation](https://ue-mcp.com/docs/control-rig-animation/) +guide for full call shapes and fixture guidance. + +Edit ranges are `[startFrame, endFrameExclusive)`. The bridge keeps an internal +support frame for Unreal's exact-duration export sample without making the +exclusive end authorable. Validate the last visible frame and exact-duration +sample separately. A non-looping endpoint must hold the intended final pose +without an adjacent-frame teleport or rotation jump; a looping endpoint must +pass the requested seam check. A large endpoint discontinuity fails the bake. diff --git a/src/action-class.ts b/src/action-class.ts index 1be412cf..32553680 100644 --- a/src/action-class.ts +++ b/src/action-class.ts @@ -153,6 +153,12 @@ const OVERRIDES: Readonly> = { // engine surface uses it as one (mirror_selected_controls), and a mutate verb // anywhere in the name wins, which is wrong for exactly this action. "animation.read_mirror_data_table": "read", + // Reads Control Rig controls and keys from an edit session. The `edit` + // segment names the session type; this action does not modify it. + "animation.read_control_rig_edit": "read", + // Reads animation data, but optionally writes validation artifacts under + // the addressed project's Saved directory. + "animation.analyze_animation": "unknown", // Reads whose first segment is not a verb at all. "level.line_trace": "read", "level.nav_project_point": "read", diff --git a/src/flow/write-methods.ts b/src/flow/write-methods.ts index bd8a5a2f..cb3cb67f 100644 --- a/src/flow/write-methods.ts +++ b/src/flow/write-methods.ts @@ -55,6 +55,26 @@ function strArray(v: unknown): string[] { * lookup. Keyed by bare bridge method name. */ const EXPLICIT: Record = { + begin_control_rig_edit: (p) => { + const path = str(p.sequencePath); + return path ? [path] : []; + }, + apply_control_rig_edits: (p) => { + const path = str(p.sequencePath); + return path ? [path] : []; + }, + bake_control_rig_edit: (p) => { + const path = str(p.outputAssetPath); + return path ? [path] : []; + }, + // IK and retargeter mutations use domain-specific target path names. Only + // the edited asset is guardable; mesh and rig references are read inputs. + configure_ik_rig: (p) => strArray([p.rigPath]), + configure_ik_retargeter: (p) => strArray([p.retargeterPath]), + set_ik_rig_mesh: (p) => strArray([p.rigPath]), + set_ik_retargeter_rig: (p) => strArray([p.retargeterPath]), + auto_align_retarget_pose: (p) => strArray([p.retargeterPath]), + reset_retarget_pose: (p) => strArray([p.retargeterPath]), // Batch rename: each entry is {sourcePath, destinationPath} or {assetPath, newName}. bulk_rename_assets: (p) => { const out: string[] = []; diff --git a/src/tools/animation.ts b/src/tools/animation.ts index 8828f1bd..c6dd4bdf 100644 --- a/src/tools/animation.ts +++ b/src/tools/animation.ts @@ -2,6 +2,238 @@ import { z } from "zod"; import { categoryTool, bp, type ToolDef } from "../types.js"; import { Vec3, Quat } from "../schemas.js"; +// Keep the Control Rig operation schema self-contained so adding it does not +// redirect JSON-schema references used by older animation actions. +const ControlRigVec3 = z.object({ + x: z.number().finite(), + y: z.number().finite(), + z: z.number().finite(), +}).strict(); +const ControlRigRotator = z.object({ pitch: z.number(), yaw: z.number(), roll: z.number() }).strict(); +const ControlRigQuaternion = z.object({ + x: z.number().finite(), + y: z.number().finite(), + z: z.number().finite(), + w: z.number().finite(), +}).strict().refine( + (rotation) => Math.abs(Math.hypot(rotation.x, rotation.y, rotation.z, rotation.w) - 1) <= 1e-3, + { message: "rotationQuaternion must be normalized" }, +); + +const ControlRigKeyTransform = z.object({ + translation: ControlRigVec3, + rotationDegrees: ControlRigRotator, + scale: ControlRigVec3, +}).strict(); + +const ControlRigSetEdit = z.object({ + op: z.literal("set"), + control: z.string().min(1), + frame: z.number().int().optional(), + frames: z.array(z.number().int()).min(1).optional(), + transform: ControlRigKeyTransform, + space: z.enum(["local", "global"]).optional(), +}).strict().refine( + (edit) => (edit.frame === undefined) !== (edit.frames === undefined), + { message: "set operations require exactly one of frame or frames" }, +); + +const ControlRigTransformKey = z.object({ + frame: z.number().int(), + transform: z.object({ + translation: ControlRigVec3, + rotationQuaternion: ControlRigQuaternion, + scale: ControlRigVec3, + }).strict(), +}).strict(); + +const ControlRigSetKeysEdit = z.object({ + op: z.literal("set_keys"), + control: z.string().min(1), + keys: z.array(ControlRigTransformKey).min(1), + space: z.enum(["local", "global"]).optional(), +}).strict().refine( + (edit) => edit.keys.every((key, index) => index === 0 || key.frame > edit.keys[index - 1].frame), + { message: "set_keys frames must be strictly increasing" }, +); + +const ControlRigOffsetEdit = z.object({ + op: z.literal("offset"), + control: z.string().min(1), + startFrame: z.number().int(), + endFrame: z.number().int(), + translationCm: ControlRigVec3.optional(), + rotationDegrees: ControlRigRotator.optional(), + scaleMultiplier: ControlRigVec3.optional(), + space: z.enum(["local", "global"]).optional(), + blendInFrames: z.number().int().nonnegative().optional(), + blendOutFrames: z.number().int().nonnegative().optional(), +}).strict() + .refine((edit) => edit.endFrame >= edit.startFrame, { + message: "offset endFrame must be greater than or equal to startFrame", + }) + .refine( + (edit) => edit.translationCm !== undefined || edit.rotationDegrees !== undefined || edit.scaleMultiplier !== undefined, + { message: "offset operations require translationCm, rotationDegrees, or scaleMultiplier" }, + ); + +const ControlRigContactTarget = z.object({ + translation: ControlRigVec3, + rotationQuaternion: ControlRigQuaternion.optional(), +}).strict(); + +const ControlRigContactLockEdit = z.object({ + op: z.literal("contact_lock"), + control: z.string().min(1), + drivenReference: z.string().min(1).optional(), + startFrame: z.number().int(), + endFrame: z.number().int(), + target: ControlRigContactTarget, + blendInFrames: z.number().int().nonnegative().optional(), + blendOutFrames: z.number().int().nonnegative().optional(), + stabilizeControls: z.array(z.string().min(1)).max(8).optional(), + positionToleranceCm: z.number().finite().positive().max(100).optional(), + rotationToleranceDegrees: z.number().finite().positive().max(180).optional(), +}).strict().superRefine((edit, context) => { + if (edit.endFrame < edit.startFrame) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["endFrame"], message: "contact_lock endFrame must be at least startFrame" }); + return; + } + + const intervalCount = edit.endFrame - edit.startFrame; + const blendIn = edit.blendInFrames ?? 0; + const blendOut = edit.blendOutFrames ?? 0; + if (blendIn + blendOut > intervalCount) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["blendOutFrames"], + message: "contact_lock blends must leave at least one fully constrained frame", + }); + } + + const controlKey = edit.control.toLowerCase(); + const stabilizerKeys = (edit.stabilizeControls ?? []).map((control) => control.toLowerCase()); + if (stabilizerKeys.includes(controlKey)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["stabilizeControls"], + message: "contact_lock control cannot also be a stabilizer", + }); + } + if (new Set(stabilizerKeys).size !== stabilizerKeys.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["stabilizeControls"], + message: "contact_lock stabilizers must be unique", + }); + } + + const frameCount = intervalCount + 1; + if (frameCount * (stabilizerKeys.length + 1) > 100_000) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["endFrame"], + message: "contact_lock is limited to 100000 control-frame cells", + }); + } +}); + +const ControlRigSetBoolEdit = z.object({ + op: z.literal("set_bool"), + control: z.string().min(1), + frame: z.number().int().optional(), + frames: z.array(z.number().int()).min(1).optional(), + value: z.boolean(), +}).strict().refine( + (edit) => (edit.frame === undefined) !== (edit.frames === undefined), + { message: "set_bool operations require exactly one of frame or frames" }, +); + +const ControlRigSetFloatEdit = z.object({ + op: z.literal("set_float"), + control: z.string().min(1), + frame: z.number().int().optional(), + frames: z.array(z.number().int()).min(1).optional(), + value: z.number().finite(), +}).strict().refine( + (edit) => (edit.frame === undefined) !== (edit.frames === undefined), + { message: "set_float operations require exactly one of frame or frames" }, +); + +const ControlRigSetIntEdit = z.object({ + op: z.literal("set_int"), + control: z.string().min(1), + frame: z.number().int().optional(), + frames: z.array(z.number().int()).min(1).optional(), + value: z.number().int(), +}).strict().refine( + (edit) => (edit.frame === undefined) !== (edit.frames === undefined), + { message: "set_int operations require exactly one of frame or frames" }, +); + +const ControlRigEditOperation = z.union([ + ControlRigSetEdit, + ControlRigSetKeysEdit, + ControlRigOffsetEdit, + ControlRigContactLockEdit, + ControlRigSetBoolEdit, + ControlRigSetFloatEdit, + ControlRigSetIntEdit, +]); + +const IKRigAuthoringChain = z.object({ + name: z.string().min(1), + startBone: z.string().min(1), + endBone: z.string().min(1), + goal: z.string().min(1).optional(), +}).strict(); + +const IKRigFullBodyGoal = z.object({ + name: z.string().min(1), + bone: z.string().min(1), + positionAlpha: z.number().finite().min(0).max(1).optional(), + rotationAlpha: z.number().finite().min(0).max(1).optional(), + chainDepth: z.number().int().nonnegative().optional(), + strengthAlpha: z.number().finite().min(0).max(1).optional(), + pullChainAlpha: z.number().finite().min(0).max(1).optional(), + pinRotation: z.number().finite().min(0).max(1).optional(), +}).strict(); + +const IKRigFullBodySettings = z.object({ + solverIndex: z.number().int().nonnegative().optional(), + rootBone: z.string().min(1), + enabled: z.boolean().optional(), + goals: z.array(IKRigFullBodyGoal).min(1).max(256), +}).strict(); + +const IKRigExclusion = z.object({ + bone: z.string().min(1), + excluded: z.boolean(), +}).strict(); + +const IKRetargetChainMapping = z.object({ + targetChain: z.string().min(1), + sourceChain: z.string().min(1).nullable().optional(), +}).strict(); + +const IKRetargetPose = z.object({ + side: z.enum(["source", "target"]), + name: z.string().min(1), + create: z.boolean().optional(), + reset: z.boolean().optional(), + autoAlign: z.enum(["chain_to_chain", "mesh_to_mesh", "local_axes", "global_axes"]).optional(), + bones: z.array(z.string().min(1)).max(10_000).optional(), + rotationOffsets: z.array(z.object({ + bone: z.string().min(1), + rotationQuaternion: ControlRigQuaternion, + }).strict()).max(10_000).optional(), + rootOffsetZ: z.number().finite().optional(), + snapBoneToGround: z.string().min(1).optional(), +}).strict().refine( + (pose) => pose.rootOffsetZ === undefined || pose.snapBoneToGround === undefined, + { message: "retarget pose rootOffsetZ and snapBoneToGround are mutually exclusive" }, +); + export const animationTool: ToolDef = categoryTool( "animation", "Animation assets, skeletons, montages, blendspaces, anim blueprints, physics assets.", @@ -51,22 +283,29 @@ export const animationTool: ToolDef = categoryTool( remove_montage_segment: bp("Remove a segment from a montage slot by index, then relay out the remaining segments and rewrite the montage length. Idempotent: alreadyDeleted=true when the slot already holds no segments. Params: assetPath, segmentIndex, slotName?, slotIndex? (default 0) (#826)", "remove_montage_segment", (p) => ({ assetPath: p.assetPath, segmentIndex: p.segmentIndex, slotName: p.slotName, slotIndex: p.slotIndex })), list_montage_segments: bp("List every slot's segments on a montage so a caller can address them by index: animation path, startPos/endPos trim, playRate, loopCount, track position and length per segment, plus the sections with the slot and segment each one links to. Params: assetPath, slotName? (filter to one slot) (#826)", "list_montage_segments", (p) => ({ assetPath: p.assetPath, slotName: p.slotName })), create_ik_rig: bp("Create IKRigDefinition asset, optionally with retargetRoot + chains[]. Params: name, skeletalMeshPath, packagePath?, retargetRoot?, chains?: [{name, startBone, endBone, goal?}]", "create_ik_rig"), - read_ik_rig: bp("Read IK Rig chains, solvers, skeleton. Params: assetPath", "read_ik_rig"), + read_ik_rig: bp("Read an IK Rig's preview mesh, skeleton roots/bones, ancestry-validated chains and goal assignments, concrete goals, exclusions, and structured solver/FBIK effector state. Params: assetPath", "read_ik_rig"), + configure_ik_rig: bp("UE 5.8 only. Author an existing IK Rig through UIKRigController with strict bone, ancestry, goal, and setting validation, native readback, one transaction, and checked save; older engines return unsupported_engine_version. autoSetup='retarget' installs the native retarget definition; 'full_body' installs the retarget definition then Full Body IK before requested desired-state upserts. Params: rigPath, autoSetup? ('retarget'|'full_body'), retargetRoot?, rootMotionBone?, chains?: [{name,startBone,endBone,goal?}], fullBodyIK?: {solverIndex?,rootBone,enabled?,goals:[{name,bone,positionAlpha?,rotationAlpha?,chainDepth?,strengthAlpha?,pullChainAlpha?,pinRotation?}]}, exclusions?: [{bone,excluded}].", "configure_ik_rig", (p) => ({ rigPath: p.rigPath, autoSetup: p.autoSetup, retargetRoot: p.retargetRoot, rootMotionBone: p.rootMotionBone, chains: p.chains, fullBodyIK: p.fullBodyIK, exclusions: p.exclusions })), list_control_rig_variables: bp("List ControlRig variables and hierarchy. Params: assetPath", "list_control_rig_variables"), read_control_rig_graph: bp("Read a Control Rig's RigVM models: every graph with its nodes (name, node path, class), each node's pins (name, pin path, cppType, direction, execute flag, default value, nested sub-pins) and the links between them, plus full member-variable metadata (type, subtype, array-ness, default, public/read-only). list_control_rig_variables only ever reported a node COUNT, which is not enough to verify solver wiring (#774). Params: assetPath, graphName? (substring filter), includePins? (default true), includeDefaults? (default true), includeLinks? (default true), limit? (nodes per graph, default 200)", "read_control_rig_graph", (p) => ({ assetPath: p.assetPath, graphName: p.graphName, includePins: p.includePins, includeDefaults: p.includeDefaults, includeLinks: p.includeLinks, limit: p.limit })), read_control_rig_hierarchy: bp("Read a Control Rig's per-element hierarchy metadata: each element's name, type (Bone|Control|Null|Curve...), index, and parent. Params: assetPath (#619)", "read_control_rig_hierarchy", (p) => ({ assetPath: p.assetPath })), + begin_control_rig_edit: bp("UE 5.8 only. Create a Sequencer Control Rig editing session over a source AnimSequence; native returns unsupported_engine_version on older engines. Baseline first: before this call, reuse or create a Control Rig for the target character, bind/import the exact target skeleton, add the intended controls, author Forward Solve, add Backward/Inverse Solve, verify it with read_control_rig_hierarchy/read_control_rig_graph, and pass an unchanged source round-trip. For a new baseline, the bundled Epic 5.8 controlrig actions include epic_create, epic_import_bones_from_asset, epic_add_control, and epic_add_backward_solve_graph; epic_create alone is not a usable rig. There is no silent fallback to raw bone-key authoring. rigMode='fk' uses UFKControlRig only when generated FK controls are sufficient; rigMode='asset' requires the verified controlRigPath and rejects rigs without inverse execution. bindingTag is the stable natural key for replay. onConflict is skip|error (default error); existing sessions are never modified. layered defaults false. startFrame is inclusive and endFrame is exclusive. Params: sequencePath, skeletalMeshPath, sourceAnimationPath, rigMode ('fk'|'asset'), controlRigPath?, layered?, startFrame?, endFrame?, displayRate?, bindingTag?, onConflict?. Returns the resolved bindingTag/binding GUID, rig, frame range, controls and created/existed status.", "begin_control_rig_edit", (p) => ({ sequencePath: p.sequencePath, skeletalMeshPath: p.skeletalMeshPath, sourceAnimationPath: p.sourceAnimationPath, rigMode: p.rigMode, controlRigPath: p.controlRigPath, layered: p.layered, startFrame: p.startFrame, endFrame: p.endFrame, displayRate: p.displayRate, bindingTag: p.bindingTag, onConflict: p.onConflict })), + read_control_rig_edit: bp("UE 5.8 only. Read transform, bool, float/scale-float, and integer/enum controls from a Control Rig editing session without changing editor state; native returns unsupported_engine_version on older engines and has no silent fallback. Params: sequencePath, bindingTag, controlNames?, frames?, space? ('local'|'global'). Scalar samples return value instead of transform. Control metadata includes native controlType, animatable, and enum path/options where applicable. Returns session identity, layered mode, range/rate, filtered control metadata, and requested frame samples.", "read_control_rig_edit", (p) => ({ sequencePath: p.sequencePath, bindingTag: p.bindingTag, controlNames: p.controlNames, frames: p.frames, space: p.space })), + apply_control_rig_edits: bp("UE 5.8 only. Apply typed Control Rig edits in one transaction; native returns unsupported_engine_version on older engines. There is no silent fallback to raw bone tracks. set_keys writes strictly ordered full per-frame transforms from normalized quaternions and preserves shortest-arc quaternion continuity. A set operation writes one full absolute transform at frame or frames. An offset operation applies translation/rotation/scale deltas across an inclusive frame range with optional edge blends. contact_lock densely constrains a translatable driver control, or an optional driven bone/socket reference, to a fixed component-space target with smooth edge blends and optional pole/control stabilization. Driver and stabilizer keys are read back transactionally. A drivenReference contact returns verification='bake_and_analyze_required'; bake it and analyze every constrained frame before accepting the bone/socket result. set_bool, set_float, and set_int key matching scalar controls; enum controls use set_int with one of the integer values reported in enumOptions. Params: sequencePath, bindingTag, operations[] where set_keys={op:'set_keys',control,keys:[{frame,transform:{translation,rotationQuaternion,scale}}],space?}, set={op:'set',control,frame|frames,transform:{translation,rotationDegrees,scale},space?}, offset={op:'offset',control,startFrame,endFrame,translationCm?,rotationDegrees?,scaleMultiplier?,space?,blendInFrames?,blendOutFrames?}, contact_lock={op:'contact_lock',control,drivenReference?,startFrame,endFrame,target:{translation,rotationQuaternion?},blendInFrames?,blendOutFrames?,stabilizeControls?,positionToleranceCm?,rotationToleranceDegrees?}, set_bool={op:'set_bool',control,frame|frames,value}, set_float={op:'set_float',control,frame|frames,value}, or set_int={op:'set_int',control,frame|frames,value}. Sequencer's current interpolation mode is retained. Returns per-operation counts, affected controls/frames, and contactQa summaries; a failed key/readback batch is undone.", "apply_control_rig_edits", (p) => ({ sequencePath: p.sequencePath, bindingTag: p.bindingTag, operations: p.operations })), + bake_control_rig_edit: bp("UE 5.8 only. Bake the evaluated Control Rig session to a new AnimSequence asset; native returns unsupported_engine_version on older engines and has no raw-track fallback. The source LevelSequence remains unchanged. outputAssetPath is the output natural key; onConflict is skip|error (default error), never overwrite. Key reduction and Sequencer links are not supported yet, so reduceKeys/createLink must be false or omitted. Params: sequencePath, bindingTag, outputAssetPath, frameRate?, reduceKeys?, tolerance?, createLink?, onConflict?. Returns output asset metadata, frame/rate counts, status, and delete-created-asset rollback.", "bake_control_rig_edit", (p) => ({ sequencePath: p.sequencePath, bindingTag: p.bindingTag, outputAssetPath: p.outputAssetPath, frameRate: p.frameRate, reduceKeys: p.reduceKeys, tolerance: p.tolerance, createLink: p.createLink, onConflict: p.onConflict })), + analyze_animation: bp("Cross-version, data-driven AnimSequence inspection using the native animation APIs available in the compiled engine. Samples an AnimSequence and reports deterministic numeric motion diagnostics without Python or viewport inference. Params: assetPath (required AnimSequence), skeletalMeshPath?, boneNames?, frames?, sampleRate?, loop?, outputDirectory? (must resolve under Project/Saved/Codex/AnimationQA and must not already contain artifacts). Returns source/rate/range metadata, sampled local/component transforms, root-motion and continuity metrics, and any written analysis artifacts.", "analyze_animation", (p) => ({ assetPath: p.assetPath, skeletalMeshPath: p.skeletalMeshPath, boneNames: p.boneNames, frames: p.frames, sampleRate: p.sampleRate, loop: p.loop, outputDirectory: p.outputDirectory })), set_root_motion: bp("Set root motion settings on AnimSequence. Params: assetPath, enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?", "set_root_motion_settings", (p) => ({ path: p.assetPath, enableRootMotion: p.enableRootMotion, forceRootLock: p.forceRootLock, useNormalizedRootMotionScale: p.useNormalizedRootMotionScale, rootMotionRootLock: p.rootMotionRootLock })), add_virtual_bone: bp("Add virtual bone. Params: skeletonPath, sourceBone, targetBone", "add_virtual_bone"), remove_virtual_bone: bp("Remove virtual bone. Params: skeletonPath, virtualBoneName", "remove_virtual_bone"), create_composite: bp("Create AnimComposite. Params: name, skeletonPath, packagePath?", "create_anim_composite"), list_modifiers: bp("List applied animation modifiers. Params: assetPath", "list_anim_modifiers", (p) => ({ path: p.assetPath })), create_ik_retargeter: bp("Create IKRetargeter asset and (default) initialize the UE 5.7 ops stack: assigns sourceRig+targetRig to all ops, runs AutoMapChains. Returns chainsMapped count. Params: name, packagePath?, sourceRig?, targetRig?, autoMapChains? (default true) (#246)", "create_ik_retargeter", (p) => ({ name: p.name, packagePath: p.packagePath, sourceRig: p.sourceRig, targetRig: p.targetRig, autoMapChains: p.autoMapChains, onConflict: p.onConflict })), - read_ik_retargeter: bp("Read IKRetargeter: source/target rigs and chain mappings. Params: assetPath (#246)", "read_ik_retargeter", (p) => ({ assetPath: p.assetPath })), + read_ik_retargeter: bp("Read an IK Retargeter's source/target rigs and preview meshes, flattened and per-op chain mappings, typed op stack, and all named/current pose offsets when the compiled engine exposes them. Params: assetPath (#246)", "read_ik_retargeter", (p) => ({ assetPath: p.assetPath })), + configure_ik_retargeter: bp("UE 5.8 only. Configure an existing IK Retargeter through UIKRetargeterController with the correct default-op and per-op rig assignment order, auto/manual chain mappings, named pose authoring, processor validation, native readback, transaction rollback, and checked save; older engines return unsupported_engine_version. Whole-pose auto-align resets that pose first: create a new pose or pass pose.reset=true to acknowledge replacement, then manual offsets are applied. Params: retargeterPath, sourceRig?, targetRig?, sourcePreviewMesh?, targetPreviewMesh?, ensureDefaultOps? (default true), autoMapMode? ('exact'|'fuzzy'|'clear'), forceRemap? (default false), chainMappings?: [{targetChain,sourceChain?:string|null}], pose?: {side,name,create?,reset?,autoAlign?,bones?,rotationOffsets?:[{bone,rotationQuaternion}],rootOffsetZ?,snapBoneToGround?}.", "configure_ik_retargeter", (p) => ({ retargeterPath: p.retargeterPath, sourceRig: p.sourceRig, targetRig: p.targetRig, sourcePreviewMesh: p.sourcePreviewMesh, targetPreviewMesh: p.targetPreviewMesh, ensureDefaultOps: p.ensureDefaultOps, autoMapMode: p.autoMapMode, forceRemap: p.forceRemap, chainMappings: p.chainMappings, pose: p.pose })), set_ik_rig_mesh: bp("Set the preview/source skeletal mesh on an EXISTING IK Rig. Params: rigPath, meshPath (#701)", "set_ik_rig_mesh", (p) => ({ rigPath: p.rigPath, meshPath: p.meshPath })), set_ik_retargeter_rig: bp("Set the source or target IK Rig on an EXISTING IK Retargeter. Params: retargeterPath, rigPath, side? (source|target, default target) (#703)", "set_ik_retargeter_rig", (p) => ({ retargeterPath: p.retargeterPath, rigPath: p.rigPath, side: p.side })), auto_align_retarget_pose: bp("Auto-align all bones of the source/target retarget pose (chain-to-chain) - fixes a retargeter that outputs a static reference pose. Params: retargeterPath, side? (source|target, default target) (#701)", "auto_align_retarget_pose", (p) => ({ retargeterPath: p.retargeterPath, side: p.side })), reset_retarget_pose: bp("Reset the current retarget pose (all bones) to the reference pose. Params: retargeterPath, side? (source|target, default target) (#701)", "reset_retarget_pose", (p) => ({ retargeterPath: p.retargeterPath, side: p.side })), - batch_retarget_animations: bp("Bake a set of source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget). Params: retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (#701)", "batch_retarget_animations", (p) => ({ retargeterPath: p.retargeterPath, sourceMesh: p.sourceMesh, targetMesh: p.targetMesh, animPaths: p.animPaths, outputPath: p.outputPath, prefix: p.prefix, suffix: p.suffix, overwrite: p.overwrite })), + batch_retarget_animations: bp("Bake validated source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget), save every output, and roll back newly created outputs if the batch is incomplete or unsavable. Overwrite is rejected. Returns mapping completeness and every unmapped target chain so partial retargets are explicit; pass requireCompleteMapping=true only when the target should have no intentional extra chains. Params: retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (must be false), requireCompleteMapping? (default false) (#701)", "batch_retarget_animations", (p) => ({ retargeterPath: p.retargeterPath, sourceMesh: p.sourceMesh, targetMesh: p.targetMesh, animPaths: p.animPaths, outputPath: p.outputPath, prefix: p.prefix, suffix: p.suffix, overwrite: p.overwrite, requireCompleteMapping: p.requireCompleteMapping })), set_anim_blueprint_skeleton: bp("Set target skeleton on AnimBP. Params: assetPath, skeletonPath", "set_anim_blueprint_skeleton"), read_bone_track: bp("Read bone transform samples from AnimSequence. Params: assetPath, boneName, frames?: [int]", "read_bone_track"), create_pose_search_database: bp("Create a PoseSearchDatabase asset (motion matching). Params: name, packagePath?, schemaPath?", "create_pose_search_database"), @@ -166,7 +405,7 @@ export const animationTool: ToolDef = categoryTool( blendIn: z.number().optional(), blendOut: z.number().optional(), numFrames: z.number().optional(), - frameRate: z.number().optional(), + frameRate: z.number().optional().describe("Frames per second for create_sequence or bake_control_rig_edit."), boneName: z.string().optional(), boneNames: z.array(z.string()).optional(), parentClass: z.string().optional().describe("Parent AnimInstance class name for create_anim_blueprint"), @@ -207,7 +446,7 @@ export const animationTool: ToolDef = categoryTool( startTime: z.number().optional().describe("Start time for montage section"), linkedSection: z.string().optional().describe("Next section name to link to"), // IK Rig params (#93) - skeletalMeshPath: z.string().optional().describe("Path to skeletal mesh for create_ik_rig / compare_curves_to_morph_targets (#656)"), + skeletalMeshPath: z.string().optional().describe("SkeletalMesh asset path. Used by IK Rig creation, curve/morph comparison, Control Rig edit setup, and animation analysis."), animPath: z.string().optional().describe("compare_curves_to_morph_targets: AnimSequence or PoseAsset path (#656)"), nodeClass: z.string().optional().describe("inspect_anim_nodes: node class substring filter, e.g. PoseDriver (#657)"), enableRootMotion: z.boolean().optional(), @@ -218,11 +457,22 @@ export const animationTool: ToolDef = categoryTool( targetBone: z.string().optional(), virtualBoneName: z.string().optional(), retargetRoot: z.string().optional().describe("Retarget root bone name for IK Rig"), + rootMotionBone: z.string().min(1).optional().describe("configure_ik_rig: root-motion bone name"), + autoSetup: z.enum(["retarget", "full_body"]).optional().describe("configure_ik_rig: optional native rig setup pass"), + fullBodyIK: IKRigFullBodySettings.optional().describe("configure_ik_rig: Full Body IK solver and desired goal/effector settings"), + exclusions: z.array(IKRigExclusion).max(2_048).optional().describe("configure_ik_rig: desired per-bone solver exclusions"), sourceRig: z.string().optional().describe("Source IKRig path for create_ik_retargeter"), targetRig: z.string().optional().describe("Target IKRig path for create_ik_retargeter"), rigPath: z.string().optional().describe("IK Rig path for set_ik_rig_mesh / set_ik_retargeter_rig (#701/#703)"), meshPath: z.string().optional().describe("Skeletal mesh path for set_ik_rig_mesh (#701)"), retargeterPath: z.string().optional().describe("IK Retargeter path (#701/#703)"), + sourcePreviewMesh: z.string().min(1).optional().describe("configure_ik_retargeter: source preview SkeletalMesh path"), + targetPreviewMesh: z.string().min(1).optional().describe("configure_ik_retargeter: target preview SkeletalMesh path"), + ensureDefaultOps: z.boolean().optional().describe("configure_ik_retargeter: ensure the complete UE 5.8 default operation stack; defaults true"), + autoMapMode: z.enum(["exact", "fuzzy", "clear"]).optional().describe("configure_ik_retargeter: native chain auto-map mode"), + forceRemap: z.boolean().optional().describe("configure_ik_retargeter: replace existing mappings during auto-map; defaults false"), + chainMappings: z.array(IKRetargetChainMapping).max(10_000).optional().describe("configure_ik_retargeter: explicit target-to-source chain overrides; null or omitted source clears"), + pose: IKRetargetPose.optional().describe("configure_ik_retargeter: named source or target pose authoring"), side: z.string().optional().describe("source|target for retargeter rig/pose actions (#701/#703)"), sourceMesh: z.string().optional().describe("batch_retarget_animations: source skeletal mesh (#701)"), targetMesh: z.string().optional().describe("batch_retarget_animations: target skeletal mesh (#701)"), @@ -230,16 +480,29 @@ export const animationTool: ToolDef = categoryTool( prefix: z.string().optional().describe("batch_retarget_animations: output name prefix (#701)"), suffix: z.string().optional().describe("batch_retarget_animations: output name suffix (#701)"), overwrite: z.boolean().optional().describe("batch_retarget_animations: overwrite existing outputs (#701)"), + requireCompleteMapping: z.boolean().optional().describe("batch_retarget_animations: reject any unmapped target chain; default false"), outputPath: z.string().optional().describe("batch_retarget_animations: destination folder for baked assets (#701)"), autoMapChains: z.boolean().optional().describe("create_ik_retargeter: assign rigs to ops + AutoMapChains after creation (default true)"), - onConflict: z.string().optional().describe("Asset-creation conflict policy: skip (default) | error | overwrite"), - frames: z.array(z.number()).optional().describe("Specific frames to sample for read_bone_track"), - chains: z.array(z.object({ - name: z.string(), - startBone: z.string(), - endBone: z.string(), - goal: z.string().optional(), - })).optional().describe("IK retarget chains for create_ik_rig"), + onConflict: z.string().optional().describe("Conflict policy. Existing asset actions use skip|error|overwrite; Control Rig begin/bake use skip|error and never overwrite."), + frames: z.array(z.number()).optional().describe("Frames to read/sample. Used by read_bone_track, read_control_rig_edit, and analyze_animation."), + // UE 5.8 Control Rig + data-driven animation editing workflow. + sourceAnimationPath: z.string().optional().describe("begin_control_rig_edit: source AnimSequence asset path (required)."), + controlRigPath: z.string().optional().describe("begin_control_rig_edit: verified baseline ControlRigBlueprint asset path for this target character; required when rigMode='asset'. Create and validate the rig first when the project/character has none."), + rigMode: z.enum(["fk", "asset"]).optional().describe("begin_control_rig_edit: use 'asset' with the verified baseline controlRigPath; use 'fk' only when generated raw FK controls are the intended editing surface."), + layered: z.boolean().optional().describe("begin_control_rig_edit: keep the source animation track active under the Control Rig layer; defaults false."), + startFrame: z.number().int().optional().describe("begin_control_rig_edit: optional inclusive edit range start frame."), + endFrame: z.number().int().optional().describe("begin_control_rig_edit: optional exclusive edit range end frame."), + displayRate: z.number().positive().optional().describe("begin_control_rig_edit: optional LevelSequence display rate in frames per second."), + bindingTag: z.string().min(1).optional().describe("Stable Control Rig edit-session natural key used by begin/read/apply/bake."), + controlNames: z.array(z.string().min(1)).min(1).optional().describe("read_control_rig_edit: optional controls to sample; omit to read every control."), + operations: z.array(ControlRigEditOperation).min(1).optional().describe("apply_control_rig_edits: typed transform/bool/float/int edit operations, including quaternion set_keys."), + outputAssetPath: z.string().optional().describe("bake_control_rig_edit: required destination AnimSequence asset path."), + reduceKeys: z.literal(false).optional().describe("bake_control_rig_edit: key reduction is not supported yet; omit or pass false."), + tolerance: z.number().nonnegative().optional().describe("bake_control_rig_edit: key-reduction tolerance."), + createLink: z.literal(false).optional().describe("bake_control_rig_edit: Sequencer links are not supported yet; omit or pass false."), + loop: z.boolean().optional().describe("analyze_animation: include end-to-start loop continuity metrics."), + outputDirectory: z.string().optional().describe("analyze_animation: optional directory under Project/Saved/Codex/AnimationQA for deterministic artifacts; relative values resolve under that root."), + chains: z.array(IKRigAuthoringChain).max(256).optional().describe("IK retarget chains for create_ik_rig or configure_ik_rig"), keyframes: z.array(z.object({ frame: z.number(), location: Vec3.optional(), @@ -258,7 +521,7 @@ export const animationTool: ToolDef = categoryTool( save: z.boolean().optional().describe("bake_keyframes_batch: save the asset after baking (default true)"), // PoseSearch (v0.7.15) schemaPath: z.string().optional().describe("Path to a UPoseSearchSchema asset"), - sequencePath: z.string().optional().describe("Animation asset path to add to a PoseSearchDatabase"), + sequencePath: z.string().optional().describe("Animation path for PoseSearch graph actions, or LevelSequence path for the UE 5.8 Control Rig edit workflow."), wait: z.boolean().optional().describe("build_pose_search_index: block until the async build resolves (default true)"), // #684 per-clip flags + bulk clip authoring mirror: z.string().optional().describe("PoseSearch clip mirror option: 'original' | 'mirrored' | 'both'"), @@ -267,7 +530,7 @@ export const animationTool: ToolDef = categoryTool( sampleEnd: z.number().optional().describe("PoseSearch clip sampling range end (seconds); [0,0] = whole clip"), clips: z.array(z.any()).optional().describe("set_pose_search_clips: array of clip entries ({sequencePath, mirror?, disableReselection?, sampleStart?, sampleEnd?, enabled?}) or bare path strings"), // Motion Matching content pipeline (schema / mirror / normalization / tuning) - sampleRate: z.number().optional().describe("create_pose_search_schema: schema sample rate (default 30)"), + sampleRate: z.number().optional().describe("Sample rate. create_pose_search_schema: schema rate (default 30); analyze_animation: optional analysis sampling rate."), addDefaultChannels: z.boolean().optional().describe("create_pose_search_schema: add Trajectory+Pose default channels (default true)"), mirrorDataTablePath: z.string().optional().describe("create_pose_search_schema: optional MirrorDataTable to bind"), bones: z.array(z.any()).optional().describe("add_pose_search_schema_pose_channel: [{bone, flags?, weight?}] or bone-name strings; also add_pose_search_schema_trajectory_channel reuses 'samples'"), @@ -301,7 +564,7 @@ export const animationTool: ToolDef = categoryTool( rootBone: z.string().optional().describe("Root bone name for bake_root_motion_from_bone (default 'root')"), axes: z.array(z.string()).optional().describe("Axes to bake ('x','y','z') for bake_root_motion_from_bone"), interpolation: z.string().optional().describe("bake_root_motion_from_bone: 'linear' (default) or 'per_frame'"), - space: z.string().optional().describe("Bone-space frame. get_bone_transforms (ref skeleton): 'local' (default) | 'component'. get_bone_transform (live actor): 'world' (default) | 'component' | 'local'"), + space: z.string().optional().describe("Transform space. Control Rig edit actions: 'local'|'global'. get_bone_transforms: 'local'|'component'. get_bone_transform: 'world'|'component'|'local'."), world: z.string().optional().describe("World scope for live actor skeletal queries: auto (default, prefer PIE), pie/game, or editor"), animation: z.string().optional().describe("AnimSequence path for add_blend_sample / set_blend_sample"), sampleIndex: z.number().optional().describe("BlendSpace sample index for set_blend_sample"), diff --git a/tests/golden/editor-connected.json b/tests/golden/editor-connected.json index 614a6603..18e1bacf 100644 --- a/tests/golden/editor-connected.json +++ b/tests/golden/editor-connected.json @@ -1,5 +1,5 @@ { - "instructions": "UE-MCP: Unreal Engine editor bridge (C++ plugin) - 24 category tools covering 783 actions, plus 830 official Unreal 5.8 tools wrapped in-process (UE 5.8+; see the epic category).\n\nEvery tool takes an \"action\" parameter that selects the operation. Call project(action=\"get_status\") first.\n\n═══ QUICK START ═══\n1. project(action=\"get_status\") - check if the editor is connected\n2. If not connected: editor(action=\"start_editor\") to launch UE\n3. level(action=\"get_outliner\") - see what's in the current level\n4. asset(action=\"list\") - browse project assets\n5. reflection(action=\"reflect_class\", className=\"StaticMeshActor\") - understand any UE class\n6. demo(action=\"step\", stepIndex=1) through 19 - run the Neon Shrine demo to see the bridge in action\n7. demo(action=\"cleanup\") - clean up after the demo\n\n═══ TOOLS ═══\n\nEvery category tool lists its own actions (and each action's parameters) in\nits description - read the description of the category you need. Categories:\nproject, asset, blueprint, level, material, animation, landscape, pcg,\nfoliage, niagara, audio, widget, editor, reflection, gameplay, gas,\nnetworking, demo, feedback, statetree, chooser, plugins, epic (830 wrapped Unreal 5.8 tools; UE 5.8+), fab.\n\n═══ TIPS ═══\n• Start with level(action=\"get_outliner\") or asset(action=\"list\") to discover what's in the project.\n• Use reflection(action=\"reflect_class\") to understand any UE class's properties.\n• asset(action=\"search\", query=\"/Game/Characters/*\") accepts wildcards.\n• For BP scripting: blueprint(action=\"search_node_types\") → blueprint(action=\"add_node\") → blueprint(action=\"connect_pins\").\n• editor(action=\"execute_python\") is the escape hatch for any Unreal Python API call.\n• Animation tools need a skeleton path - use animation(action=\"list_skeletal_meshes\") to find it.\n• Editor lifecycle: editor(action=\"stop_editor\") / editor(action=\"start_editor\") / editor(action=\"restart_editor\") manage the UE process. editor(action=\"build_project\") builds the project C++ code (stop the editor first).\n• editor(action=\"hot_reload\") triggers Live Coding compilation without restarting the editor.\n• editor(action=\"focus_on_actor\", actorLabel=\"MyActor\") snaps the viewport to any actor.\n• Log output: editor(action=\"get_log\", category=\"LogMCPBridge\") to see bridge-specific logs.\n\n═══ FLOWS - READ BEFORE ACTING ═══\n\nBefore you run bash/npm commands or chain 3+ category tool calls to\nsatisfy a user request, look at the `flows` field returned by\nproject(action=\"get_status\").\n\nThat field lists named, pre-built sequences for this project. Each\nentry has a name and description. If ANY flow's description matches\nwhat the user asked for, you MUST run it instead of building the\nsequence yourself.\n\nExamples:\n User asks | Look for a flow like\n ---------------------------------- | ------------------------------\n \"rebuild and relaunch the editor\" | rebuild\n \"run the smoke tests\" | smoke\n \"redeploy the plugin\" | deploy, redeploy\n \"package the project\" | package\n\nRun a matched flow with: flow(action=\"run\", flowName=\"\")\n\nDO NOT:\n- Skip the get_status flows check before running bash/npm yourself.\n- Author a new flow on your own. Only the user authors flows.\n- Suggest a flow for a one-off task the user is unlikely to repeat.\n\nDO suggest a new flow IF AND ONLY IF all three are true:\n 1. You just finished a sequence with 3+ steps.\n 2. The sequence had the same shape every run, with only 1-2 values\n changing.\n 3. The user is likely to ask for the same shape again.\nIn that case say: \"This sequence (X -> Y -> Z) might be worth registering\nas a flow in ue-mcp.yml. Want me to draft one?\" Then STOP. Wait.\n\n═══ FEEDBACK ═══\nIf you had to use editor(action=\"execute_python\") as a workaround because a native tool\ncouldn't handle the task, keep a mental note of what you did and why. When your task is\ncomplete, tell the user:\n \"I had to use custom Python scripts to [describe what]. Would you like to submit\n feedback to help improve ue-mcp?\"\nIf the user agrees, call feedback(action=\"submit\") with:\n • title - short, generic description of the gap (no project-specific details)\n • summary - what was attempted and why the native tool fell short\n • pythonWorkaround - the Python code that was used\n • idealTool - what tool/action should handle this natively\nThis creates a GitHub issue so the maintainers can add proper support.\n\nNot every gap belongs to ue-mcp core. Plugins (PIE Studio, Perforce, Meshy, ...)\nown their own surfaces and their own trackers. submit checks the plugin registry\nand aims the issue at the owning repo on its own, and the approval prompt lets\nthe user change it - do NOT set the repo parameter yourself unless the user\nnames a repo. feedback(action=\"route\") answers \"where would this land?\" without\nposting anything.\n", + "instructions": "UE-MCP: Unreal Engine editor bridge (C++ plugin) - 24 category tools covering 790 actions, plus 830 official Unreal 5.8 tools wrapped in-process (UE 5.8+; see the epic category).\n\nEvery tool takes an \"action\" parameter that selects the operation. Call project(action=\"get_status\") first.\n\n═══ QUICK START ═══\n1. project(action=\"get_status\") - check if the editor is connected\n2. If not connected: editor(action=\"start_editor\") to launch UE\n3. level(action=\"get_outliner\") - see what's in the current level\n4. asset(action=\"list\") - browse project assets\n5. reflection(action=\"reflect_class\", className=\"StaticMeshActor\") - understand any UE class\n6. demo(action=\"step\", stepIndex=1) through 19 - run the Neon Shrine demo to see the bridge in action\n7. demo(action=\"cleanup\") - clean up after the demo\n\n═══ TOOLS ═══\n\nEvery category tool lists its own actions (and each action's parameters) in\nits description - read the description of the category you need. Categories:\nproject, asset, blueprint, level, material, animation, landscape, pcg,\nfoliage, niagara, audio, widget, editor, reflection, gameplay, gas,\nnetworking, demo, feedback, statetree, chooser, plugins, epic (830 wrapped Unreal 5.8 tools; UE 5.8+), fab.\n\n═══ TIPS ═══\n• Start with level(action=\"get_outliner\") or asset(action=\"list\") to discover what's in the project.\n• Use reflection(action=\"reflect_class\") to understand any UE class's properties.\n• asset(action=\"search\", query=\"/Game/Characters/*\") accepts wildcards.\n• For BP scripting: blueprint(action=\"search_node_types\") → blueprint(action=\"add_node\") → blueprint(action=\"connect_pins\").\n• editor(action=\"execute_python\") is the escape hatch for any Unreal Python API call.\n• Animation tools need a skeleton path - use animation(action=\"list_skeletal_meshes\") to find it.\n• Editor lifecycle: editor(action=\"stop_editor\") / editor(action=\"start_editor\") / editor(action=\"restart_editor\") manage the UE process. editor(action=\"build_project\") builds the project C++ code (stop the editor first).\n• editor(action=\"hot_reload\") triggers Live Coding compilation without restarting the editor.\n• editor(action=\"focus_on_actor\", actorLabel=\"MyActor\") snaps the viewport to any actor.\n• Log output: editor(action=\"get_log\", category=\"LogMCPBridge\") to see bridge-specific logs.\n\n═══ FLOWS - READ BEFORE ACTING ═══\n\nBefore you run bash/npm commands or chain 3+ category tool calls to\nsatisfy a user request, look at the `flows` field returned by\nproject(action=\"get_status\").\n\nThat field lists named, pre-built sequences for this project. Each\nentry has a name and description. If ANY flow's description matches\nwhat the user asked for, you MUST run it instead of building the\nsequence yourself.\n\nExamples:\n User asks | Look for a flow like\n ---------------------------------- | ------------------------------\n \"rebuild and relaunch the editor\" | rebuild\n \"run the smoke tests\" | smoke\n \"redeploy the plugin\" | deploy, redeploy\n \"package the project\" | package\n\nRun a matched flow with: flow(action=\"run\", flowName=\"\")\n\nDO NOT:\n- Skip the get_status flows check before running bash/npm yourself.\n- Author a new flow on your own. Only the user authors flows.\n- Suggest a flow for a one-off task the user is unlikely to repeat.\n\nDO suggest a new flow IF AND ONLY IF all three are true:\n 1. You just finished a sequence with 3+ steps.\n 2. The sequence had the same shape every run, with only 1-2 values\n changing.\n 3. The user is likely to ask for the same shape again.\nIn that case say: \"This sequence (X -> Y -> Z) might be worth registering\nas a flow in ue-mcp.yml. Want me to draft one?\" Then STOP. Wait.\n\n═══ FEEDBACK ═══\nIf you had to use editor(action=\"execute_python\") as a workaround because a native tool\ncouldn't handle the task, keep a mental note of what you did and why. When your task is\ncomplete, tell the user:\n \"I had to use custom Python scripts to [describe what]. Would you like to submit\n feedback to help improve ue-mcp?\"\nIf the user agrees, call feedback(action=\"submit\") with:\n • title - short, generic description of the gap (no project-specific details)\n • summary - what was attempted and why the native tool fell short\n • pythonWorkaround - the Python code that was used\n • idealTool - what tool/action should handle this natively\nThis creates a GitHub issue so the maintainers can add proper support.\n\nNot every gap belongs to ue-mcp core. Plugins (PIE Studio, Perforce, Meshy, ...)\nown their own surfaces and their own trackers. submit checks the plugin registry\nand aims the issue at the owning repo on its own, and the approval prompt lets\nthe user change it - do NOT set the repo parameter yourself unless the user\nnames a repo. feedback(action=\"route\") answers \"where would this land?\" without\nposting anything.\n", "scenario": "editor-connected", "schemaVersion": 1, "server": { @@ -9,7 +9,7 @@ "toolCount": 27, "tools": [ { - "description": "Animation assets, skeletons, montages, blendspaces, anim blueprints, physics assets.\n\nActions:\n- read_anim_blueprint: Read AnimBP structure. Params: assetPath\n- read_montage: Read montage. Params: assetPath\n- read_sequence: Read anim sequence. Params: assetPath\n- scan_animation_tracks: Scan AnimSequence bone-track counts. Params: directory?, recursive?, assetPaths?, skeletonPath?, targetTrackCount?, includeTrackNames?\n- read_blendspace: Read blendspace. Params: assetPath\n- add_blend_sample: Append a sample to a BlendSpace. Params: assetPath, animation (AnimSequence path), position {x,y} (or flat x,y) (#248)\n- set_blend_sample: Move an existing BlendSpace sample or swap its animation. Params: assetPath, sampleIndex, position? {x,y} (or flat x,y), animation? (#272)\n- list: List anim assets. Params: directory?, recursive?\n- create_montage: Create montage. Params: animSequencePath, name?, packagePath?\n- author_montages_batch: Batch-author montages in one call: idempotent create, slot name, blend/rate/length properties, sections and notifies, then save. Every item reports success plus the failing stage (validate|create|slot|properties|sections|notifies|save) and error, so one bad item does not hide the rest. Newly created montages come back as a delete_asset_batch rollback. Each montage still holds the single segment create_montage builds. Params: items[] (each: name, animSequencePath, packagePath?, onConflict?, slotName?, trackIndex?, rateScale?, blendIn?, blendOut?, sequenceLength?, sections? [{sectionName, startTime?, linkedSection?}], notifies? [{notifyName, triggerTime, notifyClass?, properties?}])\n- create_anim_blueprint: Create AnimBP. Params: skeletonPath, name?, packagePath?, parentClass?\n- create_blendspace: Create blendspace (2D). Params: skeletonPath, name?, packagePath?, axisHorizontal?, axisVertical?\n- create_blendspace_1d: Create BlendSpace1D. Params: skeletonPath, name?, packagePath?, axisName? (default Speed), axisMin?, axisMax?, gridNum? (#459)\n- populate_blendspace: One-call axis params + samples authoring for BlendSpace 1D/2D. Params: assetPath, axis? ({name?, min?, max?, gridNum?}) for axis 0, blendspaceAxes? (per-axis array), axisHorizontal?/axisVertical? + horizontalMin/horizontalMax/verticalMin/verticalMax/gridNumHorizontal/gridNumVertical (back-compat), samples ([{animationPath, x, y?}]), clearExisting? (default true) (#459)\n- add_notify: Add notify. For PlayMontageNotify the notifyName is also written onto the spawned notify object so OnPlayMontageNotifyBegin broadcasts it (not 'None'), and montage branching-point markers refresh (#528). notifyProperties writes EditAnywhere fields onto the spawned notify object and therefore requires a notifyClass that resolves. Params: assetPath, notifyName, triggerTime, notifyClass?, notifyProperties?\n- remove_notify: Remove notify(s) by name and/or class. Pass at least one of notifyName/notifyClass; both filters AND. Idempotent: alreadyDeleted=true if no match. Params: assetPath, notifyName?, notifyClass? (#471)\n- get_skeleton_info: Read skeleton. Params: assetPath\n- list_sockets: List sockets. Params: assetPath\n- list_skeletal_meshes: List skeletal meshes. Params: directory?, recursive?\n- get_physics_asset: Read physics asset. Params: assetPath\n- create_sequence: Create blank AnimSequence. Params: name, skeletonPath, packagePath?, numFrames?, frameRate?\n- set_bone_keyframes: Set bone transform keyframes. Params: assetPath, boneName, keyframes\n- bake_keyframes_batch: Bake per-bone keyframe arrays for many bones into an AnimSequence in one call. Auto-creates each bone track first (set_bone_keyframes silently leaves a T-pose if the track is missing), wraps the batch in one transaction, and raises if any bone fails instead of reporting hollow success (#540). Params: assetPath, tracks ([{bone, keyframes:[{location,rotation{x,y,z,w},scale?}]}]), save? (default true)\n- get_bone_transforms: Read reference pose transforms for one, many, or ALL bones. Omit boneNames to return every bone with index/parentIndex/location/rotation/scale. With boneNames, returns only the named bones. Params: skeletonPath, boneNames? (omit = all bones), space? ('local' default, or 'component' for composed parent-chain transforms - retarget-chain / anatomical-scale work) (#245)\n- inspect_anim_nodes: Deep-dump the FAnimNode_* struct of anim graph nodes (PoseDriver PoseTargets/PoseAsset/RBF params/source bones, etc.) that read_anim_graph omits because it skips the 'Node' property. Params: assetPath, graphName? (default AnimGraph), nodeClass? (substring filter, e.g. 'PoseDriver') (#657)\n- compare_curves_to_morph_targets: Compare an AnimSequence/PoseAsset's curve names against a SkeletalMesh's morph target names. Returns curves[], morphTargets[], matched[], curvesWithoutMorph[], morphsWithoutCurve[] - verify authored curves drive morphs without Python. Params: animPath (AnimSequence or PoseAsset), skeletalMeshPath (#656)\n- set_montage_sequence: Replace the animation sequence in a montage slot. With segmentIndex, replaces only that one segment; without it, replaces every segment in the slot. Params: assetPath, animSequencePath, slotIndex? (default 0), segmentIndex? (#626)\n- set_montage_properties: Set montage properties. Params: assetPath, sequenceLength?, rateScale?, blendIn?, blendOut?\n- create_state_machine: Create state machine in AnimBP. Params: assetPath, name?, graphName?\n- add_state: Add state to a state machine. Params: assetPath, stateMachineName, stateName\n- add_transition: Add directed transition between states. Params: assetPath, stateMachineName, fromState, toState\n- set_state_animation: Assign anim asset to state. Params: assetPath, stateMachineName, stateName, animAssetPath\n- set_transition_blend: Set blend type/duration on transition. Params: assetPath, stateMachineName, fromState, toState, blendDuration?, blendLogic?\n- set_transition_condition: Set a transition's 'can enter transition' condition from a bool variable, keyed by transition (not graph name - every rule graph is named 'Transition' so blueprint graph tools can only reach the first). Wires VariableGet(bool) -> bCanEnterTransition, replacing any prior condition. Identify the transition by transitionGuid (from add_transition/read_state_machine) OR fromState+toState. Params: assetPath, stateMachineName, variableName (existing bool var), transitionGuid? OR fromState?+toState?, negate? (default false) (#707)\n- read_state_machine: Read state machine topology. Params: assetPath, stateMachineName\n- read_anim_graph: Read AnimBP AnimGraph nodes with properties & pins. Params: assetPath, graphName?\n- add_curve: Add float curve to AnimSequence. Params: assetPath, curveName, curveType?\n- set_anim_curve_keys: Set float-curve key VALUES on an AnimSequence (add_curve only creates an empty named curve - it cannot set keyframe values). Adds the curve if missing, then replaces its keys. Use for authoring Distance/Speed/any float curve directly. Params: assetPath, curveName, keys ([{time, value, interp?('linear'|'constant'|'cubic')}]), interpolation? (default 'linear', applied to keys without their own interp) (#712)\n- apply_animation_modifier: Instantiate a UAnimationModifier subclass and run it on an AnimSequence. Headline use: modifierClass='DistanceCurveModifier' bakes a Distance curve from the clip's root motion for distance matching (needs root motion baked first - see bake_root_motion_from_bone). Registers the modifier on the sequence so it re-applies on reimport. props sets the modifier's EditAnywhere fields (e.g. DistanceCurveModifier: {CurveName, Axis:'XY'|'X'|..., bStopAtEnd, StopSpeedThreshold, SampleRate}). Note: DistanceCurveModifier ships in the 'Animation Locomotion Library' plugin (off by default) - enable it first. Params: assetPath, modifierClass (short name or /Script path), props? (#712)\n- set_montage_slot: Set slot name on a montage track. Params: assetPath, slotName, trackIndex?\n- add_montage_section: Add composite section to montage. Pass segmentIndex (with slotName or slotIndex) to anchor the section to a specific segment: its startTime is taken from that segment and it stays linked, so inserting a segment ahead of it moves the marker with its animation. Without segmentIndex the section is a bare absolute-time marker. Params: assetPath, sectionName, startTime?, linkedSection?, segmentIndex?, slotName?, slotIndex? (#826)\n- add_montage_segment: Append (or insert) an animation segment into a montage slot's anim track. This is the only way to get more than one animation into a montage: create_montage builds exactly one segment, set_montage_sequence replaces rather than appends, and add_montage_section only writes a time marker with no animation behind it. Creates the named slot when it does not exist. Validates that the source shares the montage's skeleton and matches the track's additive type, then relays out the segments, refreshes linked sections and notifies, and rewrites the montage length. Params: assetPath, animSequencePath, slotName? (created if absent), slotIndex? (default 0, used when slotName is omitted), startPos? (trim into the source, default 0), endPos? (default source play length), playRate? (default 1, negative reverses), loopCount? (default 1), insertIndex? (default appends) (#826)\n- remove_montage_segment: Remove a segment from a montage slot by index, then relay out the remaining segments and rewrite the montage length. Idempotent: alreadyDeleted=true when the slot already holds no segments. Params: assetPath, segmentIndex, slotName?, slotIndex? (default 0) (#826)\n- list_montage_segments: List every slot's segments on a montage so a caller can address them by index: animation path, startPos/endPos trim, playRate, loopCount, track position and length per segment, plus the sections with the slot and segment each one links to. Params: assetPath, slotName? (filter to one slot) (#826)\n- create_ik_rig: Create IKRigDefinition asset, optionally with retargetRoot + chains[]. Params: name, skeletalMeshPath, packagePath?, retargetRoot?, chains?: [{name, startBone, endBone, goal?}]\n- read_ik_rig: Read IK Rig chains, solvers, skeleton. Params: assetPath\n- list_control_rig_variables: List ControlRig variables and hierarchy. Params: assetPath\n- read_control_rig_graph: Read a Control Rig's RigVM models: every graph with its nodes (name, node path, class), each node's pins (name, pin path, cppType, direction, execute flag, default value, nested sub-pins) and the links between them, plus full member-variable metadata (type, subtype, array-ness, default, public/read-only). list_control_rig_variables only ever reported a node COUNT, which is not enough to verify solver wiring (#774). Params: assetPath, graphName? (substring filter), includePins? (default true), includeDefaults? (default true), includeLinks? (default true), limit? (nodes per graph, default 200)\n- read_control_rig_hierarchy: Read a Control Rig's per-element hierarchy metadata: each element's name, type (Bone|Control|Null|Curve...), index, and parent. Params: assetPath (#619)\n- set_root_motion: Set root motion settings on AnimSequence. Params: assetPath, enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?\n- add_virtual_bone: Add virtual bone. Params: skeletonPath, sourceBone, targetBone\n- remove_virtual_bone: Remove virtual bone. Params: skeletonPath, virtualBoneName\n- create_composite: Create AnimComposite. Params: name, skeletonPath, packagePath?\n- list_modifiers: List applied animation modifiers. Params: assetPath\n- create_ik_retargeter: Create IKRetargeter asset and (default) initialize the UE 5.7 ops stack: assigns sourceRig+targetRig to all ops, runs AutoMapChains. Returns chainsMapped count. Params: name, packagePath?, sourceRig?, targetRig?, autoMapChains? (default true) (#246)\n- read_ik_retargeter: Read IKRetargeter: source/target rigs and chain mappings. Params: assetPath (#246)\n- set_ik_rig_mesh: Set the preview/source skeletal mesh on an EXISTING IK Rig. Params: rigPath, meshPath (#701)\n- set_ik_retargeter_rig: Set the source or target IK Rig on an EXISTING IK Retargeter. Params: retargeterPath, rigPath, side? (source|target, default target) (#703)\n- auto_align_retarget_pose: Auto-align all bones of the source/target retarget pose (chain-to-chain) - fixes a retargeter that outputs a static reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- reset_retarget_pose: Reset the current retarget pose (all bones) to the reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- batch_retarget_animations: Bake a set of source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget). Params: retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (#701)\n- set_anim_blueprint_skeleton: Set target skeleton on AnimBP. Params: assetPath, skeletonPath\n- read_bone_track: Read bone transform samples from AnimSequence. Params: assetPath, boneName, frames?: [int]\n- create_pose_search_database: Create a PoseSearchDatabase asset (motion matching). Params: name, packagePath?, schemaPath?\n- set_pose_search_schema: Set the Schema on an existing PoseSearchDatabase. Params: assetPath, schemaPath\n- add_pose_search_sequence: Append an AnimSequence/AnimComposite/AnimMontage/BlendSpace to a PoseSearchDatabase, with optional per-clip flags. Params: assetPath, sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled? (#684)\n- set_pose_search_clips: Author the whole clip list of a PoseSearchDatabase in one call (the 'duplicate a stock PSD, swap its clips' pipeline step). Replaces the list by default. Each clip carries per-entry flags. Params: assetPath, clips ([{sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled?}] - a bare string path also works), clearExisting? (default true). Follow with build_pose_search_index (#684)\n- build_pose_search_index: Build (or rebuild) the search index. Params: assetPath, wait? (default true)\n- read_pose_search_database: Inspect a PoseSearchDatabase: schema, animation entries, cost biases, tags. Params: assetPath\n- set_pose_search_database_settings: Tune a PoseSearchDatabase: cost biases, KD-tree neighbours, search mode, PCA components, normalization set. Params: assetPath, continuingPoseCostBias?, baseCostBias?, loopingCostBias?, kdTreeQueryNumNeighbors?, numberOfPrincipalComponents?, poseSearchMode? ('bruteforce'|'pcakdtree'|'vptree'|'eventonly'), normalizationSetPath? (motion matching)\n- create_pose_search_schema: Create a PoseSearchSchema (the feature definition a database indexes against). Binds a skeleton (and optional mirror table) and, by default, adds Trajectory+Pose default channels so the schema is immediately buildable. Refine with add_pose_search_schema_*_channel. Params: name, skeletonPath, packagePath?, mirrorDataTablePath?, sampleRate?, addDefaultChannels? (default true) (motion matching)\n- add_pose_search_schema_pose_channel: Add a Pose feature channel to a schema (samples named bones for velocity/position/rotation/phase). Params: schemaPath, bones ([{bone, flags?:['velocity','position','rotation','phase'], weight?}] - a bare bone-name string defaults to position), weight? (motion matching)\n- add_pose_search_schema_trajectory_channel: Add a Trajectory feature channel to a schema (past/future motion samples). Params: schemaPath, samples ([{offset (seconds; negative=history, positive=prediction), flags?:['position','velocity','facingDirection','velocityDirection', ...XY variants], weight?}]), weight? (motion matching)\n- read_pose_search_schema: Inspect a PoseSearchSchema: skeleton(s), mirror table, sample rate, feature channels. Params: schemaPath (motion matching)\n- create_mirror_data_table: Create a MirrorDataTable for a skeleton (needed for mirrored poses in motion matching / mirror nodes). Auto-derives bone-pair rows from find/replace expressions (defaults to UE mannequin _l/_r suffix swap). Params: name, skeletonPath, packagePath?, expressions? ([{find, replace, method?:'suffix'|'prefix'|'regex'}]), mirrorAxis? (X|Y|Z, default X), mirrorRootMotion? (default true)\n- read_mirror_data_table: Inspect a MirrorDataTable: skeleton and bone-pair rows (name -> mirroredName). Params: assetPath (motion matching)\n- create_pose_search_normalization_set: Create a PoseSearchNormalizationSet grouping databases so they normalize their cost space together (consistent blending across a locomotion set). Assign it via set_pose_search_database_settings(normalizationSetPath). Params: name, packagePath?, databases? ([PoseSearchDatabase paths]) (motion matching)\n- add_motion_matching_node: Add a Motion Matching node to an AnimBP AnimGraph and point it at a PoseSearchDatabase (the runtime node that searches the database each frame). Connects its output to the Output Pose by default. For chooser-driven database selection, bind an anim-node function that calls SetDatabasesToSearch. Params: assetPath (AnimBP), databasePath, graphName? (default AnimGraph), connectToOutput? (default true), blendTime? (motion matching)\n- add_pose_history_node: Add a Pose History (PoseSearchHistoryCollector) node to an AnimBP AnimGraph - the Motion Matching node needs it in the graph to query pose/trajectory history. Defaults to self-generated trajectory (no external trajectory pin needed) and inserts itself into the pose chain feeding the Output Pose. Params: assetPath (AnimBP), graphName? (default AnimGraph), poseCount?, samplingInterval?, generateTrajectory? (default true), trajectoryHistoryCount?, trajectoryPredictionCount?, insertBeforeOutput? (default true) (motion matching)\n- set_motion_matching_chooser: Drive the Motion Matching node's Database from a ChooserTable so the database is selected at runtime by character state. Wires a thread-safe EvaluateChooser (result typed to PoseSearchDatabase) into the MM node's Database pin. contextSource selects what the chooser reads its columns from: 'self' (default, the anim instance - choosers branching on AnimBP variables) or 'pawn' (the owning pawn via TryGetPawnOwner - choosers branching on character/pawn state). Params: assetPath (AnimBP), chooserPath (ChooserTable), graphName? (default AnimGraph), contextSource? ('self'|'pawn') (motion matching)\n- add_sequence_evaluator: Add a Sequence Evaluator node (explicit-time player) to an AnimBP graph - the node distance matching drives by setting its ExplicitTime each frame. graphName can be the top-level AnimGraph or a state's inner graph (pass the state name). Defaults bTeleportToExplicitTime=false so time advances and root motion extracts. Connects to the Output Pose by default. Returns nodeGuid for bind_anim_node_function. Params: assetPath (AnimBP), sequencePath? (AnimSequence to evaluate), graphName? (default AnimGraph), explicitTime?, shouldLoop?, teleportToExplicitTime? (default false), connectToOutput? (default true) (#713)\n- bind_anim_node_function: Bind a thread-safe anim-node function to an anim graph node's update slot - the mechanism distance matching uses to advance a Sequence Evaluator's explicit time each frame (function calls AnimDistanceMatchingLibrary::DistanceMatchToTarget / AdvanceTimeByDistanceMatching). The function must already exist on the AnimBP (create it as a BlueprintThreadSafe function first). Identify the node by nodeGuid (from add_sequence_evaluator / add_*_node). Params: assetPath (AnimBP), nodeGuid, functionName, graphName? (default AnimGraph), binding? ('update' (default)|'becomeRelevant'|'initialUpdate') (#713)\n- set_sequence_properties: Batch-set properties on AnimSequence assets. If a path is a Montage and resolveFromMontages is true (default), resolves to its first AnimSequence. Params: assetPaths[], properties{enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?}, resolveFromMontages?\n- bake_root_motion_from_bone: Bake delta translation from a source bone (e.g. pelvis) onto the root bone across the whole sequence; compensates the source bone so world-space position is unchanged. Params: assetPath, sourceBone, rootBone? (default 'root'), axes? (default ['x','y']), interpolation? ('linear'|'per_frame', default 'linear')\n- get_bone_transform: Read a bone or socket transform on a live actor's SkeletalMeshComponent. Wraps GetBoneTransform / GetSocketTransform. Params: actorLabel, boneName (or socket name), componentName? (default: CharacterMesh0 / Mesh / first SK component), world? (auto|pie|game|editor, default auto), space? (world|component|local, default world). Returns location, rotation, scale (#420)\n- list_bones: List bones in a live actor's SkeletalMeshComponent ref skeleton (name, index, parent). Params: actorLabel, componentName?, world? (auto|pie|game|editor, default auto) (#420)\n- rebind_leader_pose: Re-bind every secondary SkeletalMeshComponent on an actor to a body component (default CharacterMesh0 / Mesh). One-call fix for the 'character explodes after rotating the actor' failure mode. Params: actorLabel, bodyComponent? (#419)\n- preview_animation: Toggle bUpdateAnimationInEditor + VisibilityBasedAnimTickOption=AlwaysTickPoseAndRefreshBones on every SkeletalMeshComponent of an actor. Bypasses the 'cannot be edited on templates' guard for level instances. Params: actorLabel, enabled (#419/#420)\n\nEpic 5.8 toolset actions (341): the epic_* actions above wrap Unreal's native ToolsetRegistry tools for this domain. Pass tool arguments via 'input'. A top-level parameter named by the wrapped tool's own schema is folded into 'input' for you, as is this category's canonical asset path when the tool takes a single asset reference. A call that is still missing a required argument is refused with the exact shape to send, instead of being dispatched (#798).", + "description": "Animation assets, skeletons, montages, blendspaces, anim blueprints, physics assets.\n\nActions:\n- read_anim_blueprint: Read AnimBP structure. Params: assetPath\n- read_montage: Read montage. Params: assetPath\n- read_sequence: Read anim sequence. Params: assetPath\n- scan_animation_tracks: Scan AnimSequence bone-track counts. Params: directory?, recursive?, assetPaths?, skeletonPath?, targetTrackCount?, includeTrackNames?\n- read_blendspace: Read blendspace. Params: assetPath\n- add_blend_sample: Append a sample to a BlendSpace. Params: assetPath, animation (AnimSequence path), position {x,y} (or flat x,y) (#248)\n- set_blend_sample: Move an existing BlendSpace sample or swap its animation. Params: assetPath, sampleIndex, position? {x,y} (or flat x,y), animation? (#272)\n- list: List anim assets. Params: directory?, recursive?\n- create_montage: Create montage. Params: animSequencePath, name?, packagePath?\n- author_montages_batch: Batch-author montages in one call: idempotent create, slot name, blend/rate/length properties, sections and notifies, then save. Every item reports success plus the failing stage (validate|create|slot|properties|sections|notifies|save) and error, so one bad item does not hide the rest. Newly created montages come back as a delete_asset_batch rollback. Each montage still holds the single segment create_montage builds. Params: items[] (each: name, animSequencePath, packagePath?, onConflict?, slotName?, trackIndex?, rateScale?, blendIn?, blendOut?, sequenceLength?, sections? [{sectionName, startTime?, linkedSection?}], notifies? [{notifyName, triggerTime, notifyClass?, properties?}])\n- create_anim_blueprint: Create AnimBP. Params: skeletonPath, name?, packagePath?, parentClass?\n- create_blendspace: Create blendspace (2D). Params: skeletonPath, name?, packagePath?, axisHorizontal?, axisVertical?\n- create_blendspace_1d: Create BlendSpace1D. Params: skeletonPath, name?, packagePath?, axisName? (default Speed), axisMin?, axisMax?, gridNum? (#459)\n- populate_blendspace: One-call axis params + samples authoring for BlendSpace 1D/2D. Params: assetPath, axis? ({name?, min?, max?, gridNum?}) for axis 0, blendspaceAxes? (per-axis array), axisHorizontal?/axisVertical? + horizontalMin/horizontalMax/verticalMin/verticalMax/gridNumHorizontal/gridNumVertical (back-compat), samples ([{animationPath, x, y?}]), clearExisting? (default true) (#459)\n- add_notify: Add notify. For PlayMontageNotify the notifyName is also written onto the spawned notify object so OnPlayMontageNotifyBegin broadcasts it (not 'None'), and montage branching-point markers refresh (#528). notifyProperties writes EditAnywhere fields onto the spawned notify object and therefore requires a notifyClass that resolves. Params: assetPath, notifyName, triggerTime, notifyClass?, notifyProperties?\n- remove_notify: Remove notify(s) by name and/or class. Pass at least one of notifyName/notifyClass; both filters AND. Idempotent: alreadyDeleted=true if no match. Params: assetPath, notifyName?, notifyClass? (#471)\n- get_skeleton_info: Read skeleton. Params: assetPath\n- list_sockets: List sockets. Params: assetPath\n- list_skeletal_meshes: List skeletal meshes. Params: directory?, recursive?\n- get_physics_asset: Read physics asset. Params: assetPath\n- create_sequence: Create blank AnimSequence. Params: name, skeletonPath, packagePath?, numFrames?, frameRate?\n- set_bone_keyframes: Set bone transform keyframes. Params: assetPath, boneName, keyframes\n- bake_keyframes_batch: Bake per-bone keyframe arrays for many bones into an AnimSequence in one call. Auto-creates each bone track first (set_bone_keyframes silently leaves a T-pose if the track is missing), wraps the batch in one transaction, and raises if any bone fails instead of reporting hollow success (#540). Params: assetPath, tracks ([{bone, keyframes:[{location,rotation{x,y,z,w},scale?}]}]), save? (default true)\n- get_bone_transforms: Read reference pose transforms for one, many, or ALL bones. Omit boneNames to return every bone with index/parentIndex/location/rotation/scale. With boneNames, returns only the named bones. Params: skeletonPath, boneNames? (omit = all bones), space? ('local' default, or 'component' for composed parent-chain transforms - retarget-chain / anatomical-scale work) (#245)\n- inspect_anim_nodes: Deep-dump the FAnimNode_* struct of anim graph nodes (PoseDriver PoseTargets/PoseAsset/RBF params/source bones, etc.) that read_anim_graph omits because it skips the 'Node' property. Params: assetPath, graphName? (default AnimGraph), nodeClass? (substring filter, e.g. 'PoseDriver') (#657)\n- compare_curves_to_morph_targets: Compare an AnimSequence/PoseAsset's curve names against a SkeletalMesh's morph target names. Returns curves[], morphTargets[], matched[], curvesWithoutMorph[], morphsWithoutCurve[] - verify authored curves drive morphs without Python. Params: animPath (AnimSequence or PoseAsset), skeletalMeshPath (#656)\n- set_montage_sequence: Replace the animation sequence in a montage slot. With segmentIndex, replaces only that one segment; without it, replaces every segment in the slot. Params: assetPath, animSequencePath, slotIndex? (default 0), segmentIndex? (#626)\n- set_montage_properties: Set montage properties. Params: assetPath, sequenceLength?, rateScale?, blendIn?, blendOut?\n- create_state_machine: Create state machine in AnimBP. Params: assetPath, name?, graphName?\n- add_state: Add state to a state machine. Params: assetPath, stateMachineName, stateName\n- add_transition: Add directed transition between states. Params: assetPath, stateMachineName, fromState, toState\n- set_state_animation: Assign anim asset to state. Params: assetPath, stateMachineName, stateName, animAssetPath\n- set_transition_blend: Set blend type/duration on transition. Params: assetPath, stateMachineName, fromState, toState, blendDuration?, blendLogic?\n- set_transition_condition: Set a transition's 'can enter transition' condition from a bool variable, keyed by transition (not graph name - every rule graph is named 'Transition' so blueprint graph tools can only reach the first). Wires VariableGet(bool) -> bCanEnterTransition, replacing any prior condition. Identify the transition by transitionGuid (from add_transition/read_state_machine) OR fromState+toState. Params: assetPath, stateMachineName, variableName (existing bool var), transitionGuid? OR fromState?+toState?, negate? (default false) (#707)\n- read_state_machine: Read state machine topology. Params: assetPath, stateMachineName\n- read_anim_graph: Read AnimBP AnimGraph nodes with properties & pins. Params: assetPath, graphName?\n- add_curve: Add float curve to AnimSequence. Params: assetPath, curveName, curveType?\n- set_anim_curve_keys: Set float-curve key VALUES on an AnimSequence (add_curve only creates an empty named curve - it cannot set keyframe values). Adds the curve if missing, then replaces its keys. Use for authoring Distance/Speed/any float curve directly. Params: assetPath, curveName, keys ([{time, value, interp?('linear'|'constant'|'cubic')}]), interpolation? (default 'linear', applied to keys without their own interp) (#712)\n- apply_animation_modifier: Instantiate a UAnimationModifier subclass and run it on an AnimSequence. Headline use: modifierClass='DistanceCurveModifier' bakes a Distance curve from the clip's root motion for distance matching (needs root motion baked first - see bake_root_motion_from_bone). Registers the modifier on the sequence so it re-applies on reimport. props sets the modifier's EditAnywhere fields (e.g. DistanceCurveModifier: {CurveName, Axis:'XY'|'X'|..., bStopAtEnd, StopSpeedThreshold, SampleRate}). Note: DistanceCurveModifier ships in the 'Animation Locomotion Library' plugin (off by default) - enable it first. Params: assetPath, modifierClass (short name or /Script path), props? (#712)\n- set_montage_slot: Set slot name on a montage track. Params: assetPath, slotName, trackIndex?\n- add_montage_section: Add composite section to montage. Pass segmentIndex (with slotName or slotIndex) to anchor the section to a specific segment: its startTime is taken from that segment and it stays linked, so inserting a segment ahead of it moves the marker with its animation. Without segmentIndex the section is a bare absolute-time marker. Params: assetPath, sectionName, startTime?, linkedSection?, segmentIndex?, slotName?, slotIndex? (#826)\n- add_montage_segment: Append (or insert) an animation segment into a montage slot's anim track. This is the only way to get more than one animation into a montage: create_montage builds exactly one segment, set_montage_sequence replaces rather than appends, and add_montage_section only writes a time marker with no animation behind it. Creates the named slot when it does not exist. Validates that the source shares the montage's skeleton and matches the track's additive type, then relays out the segments, refreshes linked sections and notifies, and rewrites the montage length. Params: assetPath, animSequencePath, slotName? (created if absent), slotIndex? (default 0, used when slotName is omitted), startPos? (trim into the source, default 0), endPos? (default source play length), playRate? (default 1, negative reverses), loopCount? (default 1), insertIndex? (default appends) (#826)\n- remove_montage_segment: Remove a segment from a montage slot by index, then relay out the remaining segments and rewrite the montage length. Idempotent: alreadyDeleted=true when the slot already holds no segments. Params: assetPath, segmentIndex, slotName?, slotIndex? (default 0) (#826)\n- list_montage_segments: List every slot's segments on a montage so a caller can address them by index: animation path, startPos/endPos trim, playRate, loopCount, track position and length per segment, plus the sections with the slot and segment each one links to. Params: assetPath, slotName? (filter to one slot) (#826)\n- create_ik_rig: Create IKRigDefinition asset, optionally with retargetRoot + chains[]. Params: name, skeletalMeshPath, packagePath?, retargetRoot?, chains?: [{name, startBone, endBone, goal?}]\n- read_ik_rig: Read an IK Rig's preview mesh, skeleton roots/bones, ancestry-validated chains and goal assignments, concrete goals, exclusions, and structured solver/FBIK effector state. Params: assetPath\n- configure_ik_rig: UE 5.8 only. Author an existing IK Rig through UIKRigController with strict bone, ancestry, goal, and setting validation, native readback, one transaction, and checked save; older engines return unsupported_engine_version. autoSetup='retarget' installs the native retarget definition; 'full_body' installs the retarget definition then Full Body IK before requested desired-state upserts. Params: rigPath, autoSetup? ('retarget'|'full_body'), retargetRoot?, rootMotionBone?, chains?: [{name,startBone,endBone,goal?}], fullBodyIK?: {solverIndex?,rootBone,enabled?,goals:[{name,bone,positionAlpha?,rotationAlpha?,chainDepth?,strengthAlpha?,pullChainAlpha?,pinRotation?}]}, exclusions?: [{bone,excluded}].\n- list_control_rig_variables: List ControlRig variables and hierarchy. Params: assetPath\n- read_control_rig_graph: Read a Control Rig's RigVM models: every graph with its nodes (name, node path, class), each node's pins (name, pin path, cppType, direction, execute flag, default value, nested sub-pins) and the links between them, plus full member-variable metadata (type, subtype, array-ness, default, public/read-only). list_control_rig_variables only ever reported a node COUNT, which is not enough to verify solver wiring (#774). Params: assetPath, graphName? (substring filter), includePins? (default true), includeDefaults? (default true), includeLinks? (default true), limit? (nodes per graph, default 200)\n- read_control_rig_hierarchy: Read a Control Rig's per-element hierarchy metadata: each element's name, type (Bone|Control|Null|Curve...), index, and parent. Params: assetPath (#619)\n- begin_control_rig_edit: UE 5.8 only. Create a Sequencer Control Rig editing session over a source AnimSequence; native returns unsupported_engine_version on older engines. Baseline first: before this call, reuse or create a Control Rig for the target character, bind/import the exact target skeleton, add the intended controls, author Forward Solve, add Backward/Inverse Solve, verify it with read_control_rig_hierarchy/read_control_rig_graph, and pass an unchanged source round-trip. For a new baseline, the bundled Epic 5.8 controlrig actions include epic_create, epic_import_bones_from_asset, epic_add_control, and epic_add_backward_solve_graph; epic_create alone is not a usable rig. There is no silent fallback to raw bone-key authoring. rigMode='fk' uses UFKControlRig only when generated FK controls are sufficient; rigMode='asset' requires the verified controlRigPath and rejects rigs without inverse execution. bindingTag is the stable natural key for replay. onConflict is skip|error (default error); existing sessions are never modified. layered defaults false. startFrame is inclusive and endFrame is exclusive. Params: sequencePath, skeletalMeshPath, sourceAnimationPath, rigMode ('fk'|'asset'), controlRigPath?, layered?, startFrame?, endFrame?, displayRate?, bindingTag?, onConflict?. Returns the resolved bindingTag/binding GUID, rig, frame range, controls and created/existed status.\n- read_control_rig_edit: UE 5.8 only. Read transform, bool, float/scale-float, and integer/enum controls from a Control Rig editing session without changing editor state; native returns unsupported_engine_version on older engines and has no silent fallback. Params: sequencePath, bindingTag, controlNames?, frames?, space? ('local'|'global'). Scalar samples return value instead of transform. Control metadata includes native controlType, animatable, and enum path/options where applicable. Returns session identity, layered mode, range/rate, filtered control metadata, and requested frame samples.\n- apply_control_rig_edits: UE 5.8 only. Apply typed Control Rig edits in one transaction; native returns unsupported_engine_version on older engines. There is no silent fallback to raw bone tracks. set_keys writes strictly ordered full per-frame transforms from normalized quaternions and preserves shortest-arc quaternion continuity. A set operation writes one full absolute transform at frame or frames. An offset operation applies translation/rotation/scale deltas across an inclusive frame range with optional edge blends. contact_lock densely constrains a translatable driver control, or an optional driven bone/socket reference, to a fixed component-space target with smooth edge blends and optional pole/control stabilization. Driver and stabilizer keys are read back transactionally. A drivenReference contact returns verification='bake_and_analyze_required'; bake it and analyze every constrained frame before accepting the bone/socket result. set_bool, set_float, and set_int key matching scalar controls; enum controls use set_int with one of the integer values reported in enumOptions. Params: sequencePath, bindingTag, operations[] where set_keys={op:'set_keys',control,keys:[{frame,transform:{translation,rotationQuaternion,scale}}],space?}, set={op:'set',control,frame|frames,transform:{translation,rotationDegrees,scale},space?}, offset={op:'offset',control,startFrame,endFrame,translationCm?,rotationDegrees?,scaleMultiplier?,space?,blendInFrames?,blendOutFrames?}, contact_lock={op:'contact_lock',control,drivenReference?,startFrame,endFrame,target:{translation,rotationQuaternion?},blendInFrames?,blendOutFrames?,stabilizeControls?,positionToleranceCm?,rotationToleranceDegrees?}, set_bool={op:'set_bool',control,frame|frames,value}, set_float={op:'set_float',control,frame|frames,value}, or set_int={op:'set_int',control,frame|frames,value}. Sequencer's current interpolation mode is retained. Returns per-operation counts, affected controls/frames, and contactQa summaries; a failed key/readback batch is undone.\n- bake_control_rig_edit: UE 5.8 only. Bake the evaluated Control Rig session to a new AnimSequence asset; native returns unsupported_engine_version on older engines and has no raw-track fallback. The source LevelSequence remains unchanged. outputAssetPath is the output natural key; onConflict is skip|error (default error), never overwrite. Key reduction and Sequencer links are not supported yet, so reduceKeys/createLink must be false or omitted. Params: sequencePath, bindingTag, outputAssetPath, frameRate?, reduceKeys?, tolerance?, createLink?, onConflict?. Returns output asset metadata, frame/rate counts, status, and delete-created-asset rollback.\n- analyze_animation: Cross-version, data-driven AnimSequence inspection using the native animation APIs available in the compiled engine. Samples an AnimSequence and reports deterministic numeric motion diagnostics without Python or viewport inference. Params: assetPath (required AnimSequence), skeletalMeshPath?, boneNames?, frames?, sampleRate?, loop?, outputDirectory? (must resolve under Project/Saved/Codex/AnimationQA and must not already contain artifacts). Returns source/rate/range metadata, sampled local/component transforms, root-motion and continuity metrics, and any written analysis artifacts.\n- set_root_motion: Set root motion settings on AnimSequence. Params: assetPath, enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?\n- add_virtual_bone: Add virtual bone. Params: skeletonPath, sourceBone, targetBone\n- remove_virtual_bone: Remove virtual bone. Params: skeletonPath, virtualBoneName\n- create_composite: Create AnimComposite. Params: name, skeletonPath, packagePath?\n- list_modifiers: List applied animation modifiers. Params: assetPath\n- create_ik_retargeter: Create IKRetargeter asset and (default) initialize the UE 5.7 ops stack: assigns sourceRig+targetRig to all ops, runs AutoMapChains. Returns chainsMapped count. Params: name, packagePath?, sourceRig?, targetRig?, autoMapChains? (default true) (#246)\n- read_ik_retargeter: Read an IK Retargeter's source/target rigs and preview meshes, flattened and per-op chain mappings, typed op stack, and all named/current pose offsets when the compiled engine exposes them. Params: assetPath (#246)\n- configure_ik_retargeter: UE 5.8 only. Configure an existing IK Retargeter through UIKRetargeterController with the correct default-op and per-op rig assignment order, auto/manual chain mappings, named pose authoring, processor validation, native readback, transaction rollback, and checked save; older engines return unsupported_engine_version. Whole-pose auto-align resets that pose first: create a new pose or pass pose.reset=true to acknowledge replacement, then manual offsets are applied. Params: retargeterPath, sourceRig?, targetRig?, sourcePreviewMesh?, targetPreviewMesh?, ensureDefaultOps? (default true), autoMapMode? ('exact'|'fuzzy'|'clear'), forceRemap? (default false), chainMappings?: [{targetChain,sourceChain?:string|null}], pose?: {side,name,create?,reset?,autoAlign?,bones?,rotationOffsets?:[{bone,rotationQuaternion}],rootOffsetZ?,snapBoneToGround?}.\n- set_ik_rig_mesh: Set the preview/source skeletal mesh on an EXISTING IK Rig. Params: rigPath, meshPath (#701)\n- set_ik_retargeter_rig: Set the source or target IK Rig on an EXISTING IK Retargeter. Params: retargeterPath, rigPath, side? (source|target, default target) (#703)\n- auto_align_retarget_pose: Auto-align all bones of the source/target retarget pose (chain-to-chain) - fixes a retargeter that outputs a static reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- reset_retarget_pose: Reset the current retarget pose (all bones) to the reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- batch_retarget_animations: Bake validated source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget), save every output, and roll back newly created outputs if the batch is incomplete or unsavable. Overwrite is rejected. Returns mapping completeness and every unmapped target chain so partial retargets are explicit; pass requireCompleteMapping=true only when the target should have no intentional extra chains. Params: retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (must be false), requireCompleteMapping? (default false) (#701)\n- set_anim_blueprint_skeleton: Set target skeleton on AnimBP. Params: assetPath, skeletonPath\n- read_bone_track: Read bone transform samples from AnimSequence. Params: assetPath, boneName, frames?: [int]\n- create_pose_search_database: Create a PoseSearchDatabase asset (motion matching). Params: name, packagePath?, schemaPath?\n- set_pose_search_schema: Set the Schema on an existing PoseSearchDatabase. Params: assetPath, schemaPath\n- add_pose_search_sequence: Append an AnimSequence/AnimComposite/AnimMontage/BlendSpace to a PoseSearchDatabase, with optional per-clip flags. Params: assetPath, sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled? (#684)\n- set_pose_search_clips: Author the whole clip list of a PoseSearchDatabase in one call (the 'duplicate a stock PSD, swap its clips' pipeline step). Replaces the list by default. Each clip carries per-entry flags. Params: assetPath, clips ([{sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled?}] - a bare string path also works), clearExisting? (default true). Follow with build_pose_search_index (#684)\n- build_pose_search_index: Build (or rebuild) the search index. Params: assetPath, wait? (default true)\n- read_pose_search_database: Inspect a PoseSearchDatabase: schema, animation entries, cost biases, tags. Params: assetPath\n- set_pose_search_database_settings: Tune a PoseSearchDatabase: cost biases, KD-tree neighbours, search mode, PCA components, normalization set. Params: assetPath, continuingPoseCostBias?, baseCostBias?, loopingCostBias?, kdTreeQueryNumNeighbors?, numberOfPrincipalComponents?, poseSearchMode? ('bruteforce'|'pcakdtree'|'vptree'|'eventonly'), normalizationSetPath? (motion matching)\n- create_pose_search_schema: Create a PoseSearchSchema (the feature definition a database indexes against). Binds a skeleton (and optional mirror table) and, by default, adds Trajectory+Pose default channels so the schema is immediately buildable. Refine with add_pose_search_schema_*_channel. Params: name, skeletonPath, packagePath?, mirrorDataTablePath?, sampleRate?, addDefaultChannels? (default true) (motion matching)\n- add_pose_search_schema_pose_channel: Add a Pose feature channel to a schema (samples named bones for velocity/position/rotation/phase). Params: schemaPath, bones ([{bone, flags?:['velocity','position','rotation','phase'], weight?}] - a bare bone-name string defaults to position), weight? (motion matching)\n- add_pose_search_schema_trajectory_channel: Add a Trajectory feature channel to a schema (past/future motion samples). Params: schemaPath, samples ([{offset (seconds; negative=history, positive=prediction), flags?:['position','velocity','facingDirection','velocityDirection', ...XY variants], weight?}]), weight? (motion matching)\n- read_pose_search_schema: Inspect a PoseSearchSchema: skeleton(s), mirror table, sample rate, feature channels. Params: schemaPath (motion matching)\n- create_mirror_data_table: Create a MirrorDataTable for a skeleton (needed for mirrored poses in motion matching / mirror nodes). Auto-derives bone-pair rows from find/replace expressions (defaults to UE mannequin _l/_r suffix swap). Params: name, skeletonPath, packagePath?, expressions? ([{find, replace, method?:'suffix'|'prefix'|'regex'}]), mirrorAxis? (X|Y|Z, default X), mirrorRootMotion? (default true)\n- read_mirror_data_table: Inspect a MirrorDataTable: skeleton and bone-pair rows (name -> mirroredName). Params: assetPath (motion matching)\n- create_pose_search_normalization_set: Create a PoseSearchNormalizationSet grouping databases so they normalize their cost space together (consistent blending across a locomotion set). Assign it via set_pose_search_database_settings(normalizationSetPath). Params: name, packagePath?, databases? ([PoseSearchDatabase paths]) (motion matching)\n- add_motion_matching_node: Add a Motion Matching node to an AnimBP AnimGraph and point it at a PoseSearchDatabase (the runtime node that searches the database each frame). Connects its output to the Output Pose by default. For chooser-driven database selection, bind an anim-node function that calls SetDatabasesToSearch. Params: assetPath (AnimBP), databasePath, graphName? (default AnimGraph), connectToOutput? (default true), blendTime? (motion matching)\n- add_pose_history_node: Add a Pose History (PoseSearchHistoryCollector) node to an AnimBP AnimGraph - the Motion Matching node needs it in the graph to query pose/trajectory history. Defaults to self-generated trajectory (no external trajectory pin needed) and inserts itself into the pose chain feeding the Output Pose. Params: assetPath (AnimBP), graphName? (default AnimGraph), poseCount?, samplingInterval?, generateTrajectory? (default true), trajectoryHistoryCount?, trajectoryPredictionCount?, insertBeforeOutput? (default true) (motion matching)\n- set_motion_matching_chooser: Drive the Motion Matching node's Database from a ChooserTable so the database is selected at runtime by character state. Wires a thread-safe EvaluateChooser (result typed to PoseSearchDatabase) into the MM node's Database pin. contextSource selects what the chooser reads its columns from: 'self' (default, the anim instance - choosers branching on AnimBP variables) or 'pawn' (the owning pawn via TryGetPawnOwner - choosers branching on character/pawn state). Params: assetPath (AnimBP), chooserPath (ChooserTable), graphName? (default AnimGraph), contextSource? ('self'|'pawn') (motion matching)\n- add_sequence_evaluator: Add a Sequence Evaluator node (explicit-time player) to an AnimBP graph - the node distance matching drives by setting its ExplicitTime each frame. graphName can be the top-level AnimGraph or a state's inner graph (pass the state name). Defaults bTeleportToExplicitTime=false so time advances and root motion extracts. Connects to the Output Pose by default. Returns nodeGuid for bind_anim_node_function. Params: assetPath (AnimBP), sequencePath? (AnimSequence to evaluate), graphName? (default AnimGraph), explicitTime?, shouldLoop?, teleportToExplicitTime? (default false), connectToOutput? (default true) (#713)\n- bind_anim_node_function: Bind a thread-safe anim-node function to an anim graph node's update slot - the mechanism distance matching uses to advance a Sequence Evaluator's explicit time each frame (function calls AnimDistanceMatchingLibrary::DistanceMatchToTarget / AdvanceTimeByDistanceMatching). The function must already exist on the AnimBP (create it as a BlueprintThreadSafe function first). Identify the node by nodeGuid (from add_sequence_evaluator / add_*_node). Params: assetPath (AnimBP), nodeGuid, functionName, graphName? (default AnimGraph), binding? ('update' (default)|'becomeRelevant'|'initialUpdate') (#713)\n- set_sequence_properties: Batch-set properties on AnimSequence assets. If a path is a Montage and resolveFromMontages is true (default), resolves to its first AnimSequence. Params: assetPaths[], properties{enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?}, resolveFromMontages?\n- bake_root_motion_from_bone: Bake delta translation from a source bone (e.g. pelvis) onto the root bone across the whole sequence; compensates the source bone so world-space position is unchanged. Params: assetPath, sourceBone, rootBone? (default 'root'), axes? (default ['x','y']), interpolation? ('linear'|'per_frame', default 'linear')\n- get_bone_transform: Read a bone or socket transform on a live actor's SkeletalMeshComponent. Wraps GetBoneTransform / GetSocketTransform. Params: actorLabel, boneName (or socket name), componentName? (default: CharacterMesh0 / Mesh / first SK component), world? (auto|pie|game|editor, default auto), space? (world|component|local, default world). Returns location, rotation, scale (#420)\n- list_bones: List bones in a live actor's SkeletalMeshComponent ref skeleton (name, index, parent). Params: actorLabel, componentName?, world? (auto|pie|game|editor, default auto) (#420)\n- rebind_leader_pose: Re-bind every secondary SkeletalMeshComponent on an actor to a body component (default CharacterMesh0 / Mesh). One-call fix for the 'character explodes after rotating the actor' failure mode. Params: actorLabel, bodyComponent? (#419)\n- preview_animation: Toggle bUpdateAnimationInEditor + VisibilityBasedAnimTickOption=AlwaysTickPoseAndRefreshBones on every SkeletalMeshComponent of an actor. Bypasses the 'cannot be edited on templates' guard for level instances. Params: actorLabel, enabled (#419/#420)\n\nEpic 5.8 toolset actions (341): the epic_* actions above wrap Unreal's native ToolsetRegistry tools for this domain. Pass tool arguments via 'input'. A top-level parameter named by the wrapped tool's own schema is folded into 'input' for you, as is this category's canonical asset path when the tool takes a single asset reference. A call that is still missing a required argument is refused with the exact shape to send, instead of being dispatched (#798).", "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -63,9 +63,15 @@ "list_montage_segments", "create_ik_rig", "read_ik_rig", + "configure_ik_rig", "list_control_rig_variables", "read_control_rig_graph", "read_control_rig_hierarchy", + "begin_control_rig_edit", + "read_control_rig_edit", + "apply_control_rig_edits", + "bake_control_rig_edit", + "analyze_animation", "set_root_motion", "add_virtual_bone", "remove_virtual_bone", @@ -73,6 +79,7 @@ "list_modifiers", "create_ik_retargeter", "read_ik_retargeter", + "configure_ik_retargeter", "set_ik_rig_mesh", "set_ik_retargeter_rig", "auto_align_retarget_pose", @@ -493,6 +500,23 @@ "description": "create_ik_retargeter: assign rigs to ops + AutoMapChains after creation (default true)", "type": "boolean" }, + "autoMapMode": { + "description": "configure_ik_retargeter: native chain auto-map mode", + "enum": [ + "exact", + "fuzzy", + "clear" + ], + "type": "string" + }, + "autoSetup": { + "description": "configure_ik_rig: optional native rig setup pass", + "enum": [ + "retarget", + "full_body" + ], + "type": "string" + }, "axes": { "description": "Axes to bake ('x','y','z') for bake_root_motion_from_bone", "items": { @@ -533,6 +557,11 @@ "description": "bind_anim_node_function: 'update' (default), 'becomeRelevant', or 'initialUpdate'", "type": "string" }, + "bindingTag": { + "description": "Stable Control Rig edit-session natural key used by begin/read/apply/bake.", + "minLength": 1, + "type": "string" + }, "blendDuration": { "type": "number" }, @@ -575,21 +604,54 @@ "description": "add_pose_search_schema_pose_channel: [{bone, flags?, weight?}] or bone-name strings; also add_pose_search_schema_trajectory_channel reuses 'samples'", "type": "array" }, + "chainMappings": { + "description": "configure_ik_retargeter: explicit target-to-source chain overrides; null or omitted source clears", + "items": { + "additionalProperties": false, + "properties": { + "sourceChain": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetChain": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "targetChain" + ], + "type": "object" + }, + "maxItems": 10000, + "type": "array" + }, "chains": { - "description": "IK retarget chains for create_ik_rig", + "description": "IK retarget chains for create_ik_rig or configure_ik_rig", "items": { "additionalProperties": false, "properties": { "endBone": { + "minLength": 1, "type": "string" }, "goal": { + "minLength": 1, "type": "string" }, "name": { + "minLength": 1, "type": "string" }, "startBone": { + "minLength": 1, "type": "string" } }, @@ -600,6 +662,7 @@ ], "type": "object" }, + "maxItems": 256, "type": "array" }, "chooserPath": { @@ -630,6 +693,24 @@ "description": "set_pose_search_database_settings: bias to keep playing the current clip", "type": "number" }, + "controlNames": { + "description": "read_control_rig_edit: optional controls to sample; omit to read every control.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "controlRigPath": { + "description": "begin_control_rig_edit: verified baseline ControlRigBlueprint asset path for this target character; required when rigMode='asset'. Create and validate the rig first when the project/character has none.", + "type": "string" + }, + "createLink": { + "const": false, + "description": "bake_control_rig_edit: Sequencer links are not supported yet; omit or pass false.", + "type": "boolean" + }, "curveName": { "description": "Curve name for add_curve", "type": "string" @@ -656,6 +737,11 @@ "description": "PoseSearch clip: disallow reselecting poses from the same asset", "type": "boolean" }, + "displayRate": { + "description": "begin_control_rig_edit: optional LevelSequence display rate in frames per second.", + "exclusiveMinimum": 0, + "type": "number" + }, "enableRootMotion": { "type": "boolean" }, @@ -663,10 +749,40 @@ "description": "preview_animation: toggle on/off", "type": "boolean" }, + "endFrame": { + "description": "begin_control_rig_edit: optional exclusive edit range end frame.", + "type": "integer" + }, "endPos": { "description": "add_montage_segment: trim end inside the source animation (default: source play length)", "type": "number" }, + "ensureDefaultOps": { + "description": "configure_ik_retargeter: ensure the complete UE 5.8 default operation stack; defaults true", + "type": "boolean" + }, + "exclusions": { + "description": "configure_ik_rig: desired per-bone solver exclusions", + "items": { + "additionalProperties": false, + "properties": { + "bone": { + "minLength": 1, + "type": "string" + }, + "excluded": { + "type": "boolean" + } + }, + "required": [ + "bone", + "excluded" + ], + "type": "object" + }, + "maxItems": 2048, + "type": "array" + }, "explicitTime": { "description": "add_sequence_evaluator: initial ExplicitTime", "type": "number" @@ -675,14 +791,19 @@ "description": "create_mirror_data_table: [{find, replace, method?}] find/replace bone-name rules", "type": "array" }, + "forceRemap": { + "description": "configure_ik_retargeter: replace existing mappings during auto-map; defaults false", + "type": "boolean" + }, "forceRootLock": { "type": "boolean" }, "frameRate": { + "description": "Frames per second for create_sequence or bake_control_rig_edit.", "type": "number" }, "frames": { - "description": "Specific frames to sample for read_bone_track", + "description": "Frames to read/sample. Used by read_bone_track, read_control_rig_edit, and analyze_animation.", "items": { "type": "number" }, @@ -691,6 +812,80 @@ "fromState": { "type": "string" }, + "fullBodyIK": { + "additionalProperties": false, + "description": "configure_ik_rig: Full Body IK solver and desired goal/effector settings", + "properties": { + "enabled": { + "type": "boolean" + }, + "goals": { + "items": { + "additionalProperties": false, + "properties": { + "bone": { + "minLength": 1, + "type": "string" + }, + "chainDepth": { + "minimum": 0, + "type": "integer" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "pinRotation": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "pullChainAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rotationAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "strengthAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "name", + "bone" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "rootBone": { + "minLength": 1, + "type": "string" + }, + "solverIndex": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "rootBone", + "goals" + ], + "type": "object" + }, "functionName": { "description": "bind_anim_node_function: thread-safe anim-node function name to bind", "type": "string" @@ -946,6 +1141,10 @@ }, "type": "array" }, + "layered": { + "description": "begin_control_rig_edit: keep the source animation track active under the Control Rig layer; defaults false.", + "type": "boolean" + }, "limit": { "description": "read_control_rig_graph: max nodes reported per graph (default 200) (#774)", "type": "number" @@ -954,6 +1153,10 @@ "description": "Next section name to link to", "type": "string" }, + "loop": { + "description": "analyze_animation: include end-to-start loop continuity metrics.", + "type": "boolean" + }, "loopCount": { "description": "add_montage_segment: how many times the segment repeats (default 1)", "minimum": 1, @@ -1025,7 +1228,395 @@ "type": "number" }, "onConflict": { - "description": "Asset-creation conflict policy: skip (default) | error | overwrite", + "description": "Conflict policy. Existing asset actions use skip|error|overwrite; Control Rig begin/bake use skip|error and never overwrite.", + "type": "string" + }, + "operations": { + "description": "apply_control_rig_edits: typed transform/bool/float/int edit operations, including quaternion set_keys.", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set", + "type": "string" + }, + "space": { + "enum": [ + "local", + "global" + ], + "type": "string" + }, + "transform": { + "additionalProperties": false, + "properties": { + "rotationDegrees": { + "additionalProperties": false, + "properties": { + "pitch": { + "type": "number" + }, + "roll": { + "type": "number" + }, + "yaw": { + "type": "number" + } + }, + "required": [ + "pitch", + "yaw", + "roll" + ], + "type": "object" + }, + "scale": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + }, + "translation": { + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "type": "object" + } + }, + "required": [ + "translation", + "rotationDegrees", + "scale" + ], + "type": "object" + } + }, + "required": [ + "op", + "control", + "transform" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "keys": { + "items": { + "additionalProperties": false, + "properties": { + "frame": { + "type": "integer" + }, + "transform": { + "additionalProperties": false, + "properties": { + "rotationQuaternion": { + "$ref": "#/properties/pose/properties/rotationOffsets/items/properties/rotationQuaternion" + }, + "scale": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + }, + "translation": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + } + }, + "required": [ + "translation", + "rotationQuaternion", + "scale" + ], + "type": "object" + } + }, + "required": [ + "frame", + "transform" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_keys", + "type": "string" + }, + "space": { + "enum": [ + "local", + "global" + ], + "type": "string" + } + }, + "required": [ + "op", + "control", + "keys" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "blendInFrames": { + "minimum": 0, + "type": "integer" + }, + "blendOutFrames": { + "minimum": 0, + "type": "integer" + }, + "control": { + "minLength": 1, + "type": "string" + }, + "endFrame": { + "type": "integer" + }, + "op": { + "const": "offset", + "type": "string" + }, + "rotationDegrees": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/rotationDegrees" + }, + "scaleMultiplier": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + }, + "space": { + "enum": [ + "local", + "global" + ], + "type": "string" + }, + "startFrame": { + "type": "integer" + }, + "translationCm": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + } + }, + "required": [ + "op", + "control", + "startFrame", + "endFrame" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "blendInFrames": { + "minimum": 0, + "type": "integer" + }, + "blendOutFrames": { + "minimum": 0, + "type": "integer" + }, + "control": { + "minLength": 1, + "type": "string" + }, + "drivenReference": { + "minLength": 1, + "type": "string" + }, + "endFrame": { + "type": "integer" + }, + "op": { + "const": "contact_lock", + "type": "string" + }, + "positionToleranceCm": { + "exclusiveMinimum": 0, + "maximum": 100, + "type": "number" + }, + "rotationToleranceDegrees": { + "exclusiveMinimum": 0, + "maximum": 180, + "type": "number" + }, + "stabilizeControls": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "startFrame": { + "type": "integer" + }, + "target": { + "additionalProperties": false, + "properties": { + "rotationQuaternion": { + "$ref": "#/properties/pose/properties/rotationOffsets/items/properties/rotationQuaternion" + }, + "translation": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + } + }, + "required": [ + "translation" + ], + "type": "object" + } + }, + "required": [ + "op", + "control", + "startFrame", + "endFrame", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_bool", + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "op", + "control", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_float", + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "op", + "control", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_int", + "type": "string" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "op", + "control", + "value" + ], + "type": "object" + } + ] + }, + "minItems": 1, + "type": "array" + }, + "outputAssetPath": { + "description": "bake_control_rig_edit: required destination AnimSequence asset path.", + "type": "string" + }, + "outputDirectory": { + "description": "analyze_animation: optional directory under Project/Saved/Codex/AnimationQA for deterministic artifacts; relative values resolve under that root.", "type": "string" }, "outputPath": { @@ -1047,6 +1638,100 @@ "description": "add_montage_segment: segment play rate, negative plays in reverse (default 1)", "type": "number" }, + "pose": { + "additionalProperties": false, + "description": "configure_ik_retargeter: named source or target pose authoring", + "properties": { + "autoAlign": { + "enum": [ + "chain_to_chain", + "mesh_to_mesh", + "local_axes", + "global_axes" + ], + "type": "string" + }, + "bones": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 10000, + "type": "array" + }, + "create": { + "type": "boolean" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "reset": { + "type": "boolean" + }, + "rootOffsetZ": { + "type": "number" + }, + "rotationOffsets": { + "items": { + "additionalProperties": false, + "properties": { + "bone": { + "minLength": 1, + "type": "string" + }, + "rotationQuaternion": { + "additionalProperties": false, + "properties": { + "w": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "type": "object" + } + }, + "required": [ + "bone", + "rotationQuaternion" + ], + "type": "object" + }, + "maxItems": 10000, + "type": "array" + }, + "side": { + "enum": [ + "source", + "target" + ], + "type": "string" + }, + "snapBoneToGround": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "side", + "name" + ], + "type": "object" + }, "poseCount": { "description": "add_pose_history_node: number of history poses to retain", "type": "number" @@ -1088,6 +1773,15 @@ "recursive": { "type": "boolean" }, + "reduceKeys": { + "const": false, + "description": "bake_control_rig_edit: key reduction is not supported yet; omit or pass false.", + "type": "boolean" + }, + "requireCompleteMapping": { + "description": "batch_retarget_animations: reject any unmapped target chain; default false", + "type": "boolean" + }, "resolveFromMontages": { "description": "Resolve AnimMontage inputs to first anim reference (default true)", "type": "boolean" @@ -1100,6 +1794,14 @@ "description": "IK Retargeter path (#701/#703)", "type": "string" }, + "rigMode": { + "description": "begin_control_rig_edit: use 'asset' with the verified baseline controlRigPath; use 'fk' only when generated raw FK controls are the intended editing surface.", + "enum": [ + "fk", + "asset" + ], + "type": "string" + }, "rigPath": { "description": "IK Rig path for set_ik_rig_mesh / set_ik_retargeter_rig (#701/#703)", "type": "string" @@ -1108,6 +1810,11 @@ "description": "Root bone name for bake_root_motion_from_bone (default 'root')", "type": "string" }, + "rootMotionBone": { + "description": "configure_ik_rig: root-motion bone name", + "minLength": 1, + "type": "string" + }, "rootMotionRootLock": { "description": "RefPose|AnimFirstFrame|Zero", "type": "string" @@ -1121,7 +1828,7 @@ "type": "number" }, "sampleRate": { - "description": "create_pose_search_schema: schema sample rate (default 30)", + "description": "Sample rate. create_pose_search_schema: schema rate (default 30); analyze_animation: optional analysis sampling rate.", "type": "number" }, "sampleStart": { @@ -1160,7 +1867,7 @@ "type": "number" }, "sequencePath": { - "description": "Animation asset path to add to a PoseSearchDatabase", + "description": "Animation path for PoseSearch graph actions, or LevelSequence path for the UE 5.8 Control Rig edit workflow.", "type": "string" }, "shouldLoop": { @@ -1172,7 +1879,7 @@ "type": "string" }, "skeletalMeshPath": { - "description": "Path to skeletal mesh for create_ik_rig / compare_curves_to_morph_targets (#656)", + "description": "SkeletalMesh asset path. Used by IK Rig creation, curve/morph comparison, Control Rig edit setup, and animation analysis.", "type": "string" }, "skeletonPath": { @@ -1185,6 +1892,10 @@ "description": "Slot name for set_montage_slot. add_montage_segment: target slot, created when absent. remove_montage_segment / list_montage_segments / add_montage_section: target slot (#826)", "type": "string" }, + "sourceAnimationPath": { + "description": "begin_control_rig_edit: source AnimSequence asset path (required).", + "type": "string" + }, "sourceBone": { "type": "string" }, @@ -1192,14 +1903,23 @@ "description": "batch_retarget_animations: source skeletal mesh (#701)", "type": "string" }, + "sourcePreviewMesh": { + "description": "configure_ik_retargeter: source preview SkeletalMesh path", + "minLength": 1, + "type": "string" + }, "sourceRig": { "description": "Source IKRig path for create_ik_retargeter", "type": "string" }, "space": { - "description": "Bone-space frame. get_bone_transforms (ref skeleton): 'local' (default) | 'component'. get_bone_transform (live actor): 'world' (default) | 'component' | 'local'", + "description": "Transform space. Control Rig edit actions: 'local'|'global'. get_bone_transforms: 'local'|'component'. get_bone_transform: 'world'|'component'|'local'.", "type": "string" }, + "startFrame": { + "description": "begin_control_rig_edit: optional inclusive edit range start frame.", + "type": "integer" + }, "startPos": { "description": "add_montage_segment: trim start inside the source animation (default 0)", "type": "number" @@ -1225,6 +1945,11 @@ "description": "batch_retarget_animations: target skeletal mesh (#701)", "type": "string" }, + "targetPreviewMesh": { + "description": "configure_ik_retargeter: target preview SkeletalMesh path", + "minLength": 1, + "type": "string" + }, "targetRig": { "description": "Target IKRig path for create_ik_retargeter", "type": "string" @@ -1240,6 +1965,11 @@ "toState": { "type": "string" }, + "tolerance": { + "description": "bake_control_rig_edit: key-reduction tolerance.", + "minimum": 0, + "type": "number" + }, "trackIndex": { "description": "Slot track index (default: 0)", "type": "number" diff --git a/tests/golden/editor-down.json b/tests/golden/editor-down.json index 0b699b35..037b28dc 100644 --- a/tests/golden/editor-down.json +++ b/tests/golden/editor-down.json @@ -1,5 +1,5 @@ { - "instructions": "UE-MCP: Unreal Engine editor bridge (C++ plugin) - 24 category tools covering 783 actions, plus 830 official Unreal 5.8 tools wrapped in-process (UE 5.8+; see the epic category).\n\nEvery tool takes an \"action\" parameter that selects the operation. Call project(action=\"get_status\") first.\n\n═══ QUICK START ═══\n1. project(action=\"get_status\") - check if the editor is connected\n2. If not connected: editor(action=\"start_editor\") to launch UE\n3. level(action=\"get_outliner\") - see what's in the current level\n4. asset(action=\"list\") - browse project assets\n5. reflection(action=\"reflect_class\", className=\"StaticMeshActor\") - understand any UE class\n6. demo(action=\"step\", stepIndex=1) through 19 - run the Neon Shrine demo to see the bridge in action\n7. demo(action=\"cleanup\") - clean up after the demo\n\n═══ TOOLS ═══\n\nEvery category tool lists its own actions (and each action's parameters) in\nits description - read the description of the category you need. Categories:\nproject, asset, blueprint, level, material, animation, landscape, pcg,\nfoliage, niagara, audio, widget, editor, reflection, gameplay, gas,\nnetworking, demo, feedback, statetree, chooser, plugins, epic (830 wrapped Unreal 5.8 tools; UE 5.8+), fab.\n\n═══ TIPS ═══\n• Start with level(action=\"get_outliner\") or asset(action=\"list\") to discover what's in the project.\n• Use reflection(action=\"reflect_class\") to understand any UE class's properties.\n• asset(action=\"search\", query=\"/Game/Characters/*\") accepts wildcards.\n• For BP scripting: blueprint(action=\"search_node_types\") → blueprint(action=\"add_node\") → blueprint(action=\"connect_pins\").\n• editor(action=\"execute_python\") is the escape hatch for any Unreal Python API call.\n• Animation tools need a skeleton path - use animation(action=\"list_skeletal_meshes\") to find it.\n• Editor lifecycle: editor(action=\"stop_editor\") / editor(action=\"start_editor\") / editor(action=\"restart_editor\") manage the UE process. editor(action=\"build_project\") builds the project C++ code (stop the editor first).\n• editor(action=\"hot_reload\") triggers Live Coding compilation without restarting the editor.\n• editor(action=\"focus_on_actor\", actorLabel=\"MyActor\") snaps the viewport to any actor.\n• Log output: editor(action=\"get_log\", category=\"LogMCPBridge\") to see bridge-specific logs.\n\n═══ FLOWS - READ BEFORE ACTING ═══\n\nBefore you run bash/npm commands or chain 3+ category tool calls to\nsatisfy a user request, look at the `flows` field returned by\nproject(action=\"get_status\").\n\nThat field lists named, pre-built sequences for this project. Each\nentry has a name and description. If ANY flow's description matches\nwhat the user asked for, you MUST run it instead of building the\nsequence yourself.\n\nExamples:\n User asks | Look for a flow like\n ---------------------------------- | ------------------------------\n \"rebuild and relaunch the editor\" | rebuild\n \"run the smoke tests\" | smoke\n \"redeploy the plugin\" | deploy, redeploy\n \"package the project\" | package\n\nRun a matched flow with: flow(action=\"run\", flowName=\"\")\n\nDO NOT:\n- Skip the get_status flows check before running bash/npm yourself.\n- Author a new flow on your own. Only the user authors flows.\n- Suggest a flow for a one-off task the user is unlikely to repeat.\n\nDO suggest a new flow IF AND ONLY IF all three are true:\n 1. You just finished a sequence with 3+ steps.\n 2. The sequence had the same shape every run, with only 1-2 values\n changing.\n 3. The user is likely to ask for the same shape again.\nIn that case say: \"This sequence (X -> Y -> Z) might be worth registering\nas a flow in ue-mcp.yml. Want me to draft one?\" Then STOP. Wait.\n\n═══ FEEDBACK ═══\nIf you had to use editor(action=\"execute_python\") as a workaround because a native tool\ncouldn't handle the task, keep a mental note of what you did and why. When your task is\ncomplete, tell the user:\n \"I had to use custom Python scripts to [describe what]. Would you like to submit\n feedback to help improve ue-mcp?\"\nIf the user agrees, call feedback(action=\"submit\") with:\n • title - short, generic description of the gap (no project-specific details)\n • summary - what was attempted and why the native tool fell short\n • pythonWorkaround - the Python code that was used\n • idealTool - what tool/action should handle this natively\nThis creates a GitHub issue so the maintainers can add proper support.\n\nNot every gap belongs to ue-mcp core. Plugins (PIE Studio, Perforce, Meshy, ...)\nown their own surfaces and their own trackers. submit checks the plugin registry\nand aims the issue at the owning repo on its own, and the approval prompt lets\nthe user change it - do NOT set the repo parameter yourself unless the user\nnames a repo. feedback(action=\"route\") answers \"where would this land?\" without\nposting anything.\n", + "instructions": "UE-MCP: Unreal Engine editor bridge (C++ plugin) - 24 category tools covering 790 actions, plus 830 official Unreal 5.8 tools wrapped in-process (UE 5.8+; see the epic category).\n\nEvery tool takes an \"action\" parameter that selects the operation. Call project(action=\"get_status\") first.\n\n═══ QUICK START ═══\n1. project(action=\"get_status\") - check if the editor is connected\n2. If not connected: editor(action=\"start_editor\") to launch UE\n3. level(action=\"get_outliner\") - see what's in the current level\n4. asset(action=\"list\") - browse project assets\n5. reflection(action=\"reflect_class\", className=\"StaticMeshActor\") - understand any UE class\n6. demo(action=\"step\", stepIndex=1) through 19 - run the Neon Shrine demo to see the bridge in action\n7. demo(action=\"cleanup\") - clean up after the demo\n\n═══ TOOLS ═══\n\nEvery category tool lists its own actions (and each action's parameters) in\nits description - read the description of the category you need. Categories:\nproject, asset, blueprint, level, material, animation, landscape, pcg,\nfoliage, niagara, audio, widget, editor, reflection, gameplay, gas,\nnetworking, demo, feedback, statetree, chooser, plugins, epic (830 wrapped Unreal 5.8 tools; UE 5.8+), fab.\n\n═══ TIPS ═══\n• Start with level(action=\"get_outliner\") or asset(action=\"list\") to discover what's in the project.\n• Use reflection(action=\"reflect_class\") to understand any UE class's properties.\n• asset(action=\"search\", query=\"/Game/Characters/*\") accepts wildcards.\n• For BP scripting: blueprint(action=\"search_node_types\") → blueprint(action=\"add_node\") → blueprint(action=\"connect_pins\").\n• editor(action=\"execute_python\") is the escape hatch for any Unreal Python API call.\n• Animation tools need a skeleton path - use animation(action=\"list_skeletal_meshes\") to find it.\n• Editor lifecycle: editor(action=\"stop_editor\") / editor(action=\"start_editor\") / editor(action=\"restart_editor\") manage the UE process. editor(action=\"build_project\") builds the project C++ code (stop the editor first).\n• editor(action=\"hot_reload\") triggers Live Coding compilation without restarting the editor.\n• editor(action=\"focus_on_actor\", actorLabel=\"MyActor\") snaps the viewport to any actor.\n• Log output: editor(action=\"get_log\", category=\"LogMCPBridge\") to see bridge-specific logs.\n\n═══ FLOWS - READ BEFORE ACTING ═══\n\nBefore you run bash/npm commands or chain 3+ category tool calls to\nsatisfy a user request, look at the `flows` field returned by\nproject(action=\"get_status\").\n\nThat field lists named, pre-built sequences for this project. Each\nentry has a name and description. If ANY flow's description matches\nwhat the user asked for, you MUST run it instead of building the\nsequence yourself.\n\nExamples:\n User asks | Look for a flow like\n ---------------------------------- | ------------------------------\n \"rebuild and relaunch the editor\" | rebuild\n \"run the smoke tests\" | smoke\n \"redeploy the plugin\" | deploy, redeploy\n \"package the project\" | package\n\nRun a matched flow with: flow(action=\"run\", flowName=\"\")\n\nDO NOT:\n- Skip the get_status flows check before running bash/npm yourself.\n- Author a new flow on your own. Only the user authors flows.\n- Suggest a flow for a one-off task the user is unlikely to repeat.\n\nDO suggest a new flow IF AND ONLY IF all three are true:\n 1. You just finished a sequence with 3+ steps.\n 2. The sequence had the same shape every run, with only 1-2 values\n changing.\n 3. The user is likely to ask for the same shape again.\nIn that case say: \"This sequence (X -> Y -> Z) might be worth registering\nas a flow in ue-mcp.yml. Want me to draft one?\" Then STOP. Wait.\n\n═══ FEEDBACK ═══\nIf you had to use editor(action=\"execute_python\") as a workaround because a native tool\ncouldn't handle the task, keep a mental note of what you did and why. When your task is\ncomplete, tell the user:\n \"I had to use custom Python scripts to [describe what]. Would you like to submit\n feedback to help improve ue-mcp?\"\nIf the user agrees, call feedback(action=\"submit\") with:\n • title - short, generic description of the gap (no project-specific details)\n • summary - what was attempted and why the native tool fell short\n • pythonWorkaround - the Python code that was used\n • idealTool - what tool/action should handle this natively\nThis creates a GitHub issue so the maintainers can add proper support.\n\nNot every gap belongs to ue-mcp core. Plugins (PIE Studio, Perforce, Meshy, ...)\nown their own surfaces and their own trackers. submit checks the plugin registry\nand aims the issue at the owning repo on its own, and the approval prompt lets\nthe user change it - do NOT set the repo parameter yourself unless the user\nnames a repo. feedback(action=\"route\") answers \"where would this land?\" without\nposting anything.\n", "scenario": "editor-down", "schemaVersion": 1, "server": { @@ -9,7 +9,7 @@ "toolCount": 27, "tools": [ { - "description": "Animation assets, skeletons, montages, blendspaces, anim blueprints, physics assets.\n\nActions:\n- read_anim_blueprint: Read AnimBP structure. Params: assetPath\n- read_montage: Read montage. Params: assetPath\n- read_sequence: Read anim sequence. Params: assetPath\n- scan_animation_tracks: Scan AnimSequence bone-track counts. Params: directory?, recursive?, assetPaths?, skeletonPath?, targetTrackCount?, includeTrackNames?\n- read_blendspace: Read blendspace. Params: assetPath\n- add_blend_sample: Append a sample to a BlendSpace. Params: assetPath, animation (AnimSequence path), position {x,y} (or flat x,y) (#248)\n- set_blend_sample: Move an existing BlendSpace sample or swap its animation. Params: assetPath, sampleIndex, position? {x,y} (or flat x,y), animation? (#272)\n- list: List anim assets. Params: directory?, recursive?\n- create_montage: Create montage. Params: animSequencePath, name?, packagePath?\n- author_montages_batch: Batch-author montages in one call: idempotent create, slot name, blend/rate/length properties, sections and notifies, then save. Every item reports success plus the failing stage (validate|create|slot|properties|sections|notifies|save) and error, so one bad item does not hide the rest. Newly created montages come back as a delete_asset_batch rollback. Each montage still holds the single segment create_montage builds. Params: items[] (each: name, animSequencePath, packagePath?, onConflict?, slotName?, trackIndex?, rateScale?, blendIn?, blendOut?, sequenceLength?, sections? [{sectionName, startTime?, linkedSection?}], notifies? [{notifyName, triggerTime, notifyClass?, properties?}])\n- create_anim_blueprint: Create AnimBP. Params: skeletonPath, name?, packagePath?, parentClass?\n- create_blendspace: Create blendspace (2D). Params: skeletonPath, name?, packagePath?, axisHorizontal?, axisVertical?\n- create_blendspace_1d: Create BlendSpace1D. Params: skeletonPath, name?, packagePath?, axisName? (default Speed), axisMin?, axisMax?, gridNum? (#459)\n- populate_blendspace: One-call axis params + samples authoring for BlendSpace 1D/2D. Params: assetPath, axis? ({name?, min?, max?, gridNum?}) for axis 0, blendspaceAxes? (per-axis array), axisHorizontal?/axisVertical? + horizontalMin/horizontalMax/verticalMin/verticalMax/gridNumHorizontal/gridNumVertical (back-compat), samples ([{animationPath, x, y?}]), clearExisting? (default true) (#459)\n- add_notify: Add notify. For PlayMontageNotify the notifyName is also written onto the spawned notify object so OnPlayMontageNotifyBegin broadcasts it (not 'None'), and montage branching-point markers refresh (#528). notifyProperties writes EditAnywhere fields onto the spawned notify object and therefore requires a notifyClass that resolves. Params: assetPath, notifyName, triggerTime, notifyClass?, notifyProperties?\n- remove_notify: Remove notify(s) by name and/or class. Pass at least one of notifyName/notifyClass; both filters AND. Idempotent: alreadyDeleted=true if no match. Params: assetPath, notifyName?, notifyClass? (#471)\n- get_skeleton_info: Read skeleton. Params: assetPath\n- list_sockets: List sockets. Params: assetPath\n- list_skeletal_meshes: List skeletal meshes. Params: directory?, recursive?\n- get_physics_asset: Read physics asset. Params: assetPath\n- create_sequence: Create blank AnimSequence. Params: name, skeletonPath, packagePath?, numFrames?, frameRate?\n- set_bone_keyframes: Set bone transform keyframes. Params: assetPath, boneName, keyframes\n- bake_keyframes_batch: Bake per-bone keyframe arrays for many bones into an AnimSequence in one call. Auto-creates each bone track first (set_bone_keyframes silently leaves a T-pose if the track is missing), wraps the batch in one transaction, and raises if any bone fails instead of reporting hollow success (#540). Params: assetPath, tracks ([{bone, keyframes:[{location,rotation{x,y,z,w},scale?}]}]), save? (default true)\n- get_bone_transforms: Read reference pose transforms for one, many, or ALL bones. Omit boneNames to return every bone with index/parentIndex/location/rotation/scale. With boneNames, returns only the named bones. Params: skeletonPath, boneNames? (omit = all bones), space? ('local' default, or 'component' for composed parent-chain transforms - retarget-chain / anatomical-scale work) (#245)\n- inspect_anim_nodes: Deep-dump the FAnimNode_* struct of anim graph nodes (PoseDriver PoseTargets/PoseAsset/RBF params/source bones, etc.) that read_anim_graph omits because it skips the 'Node' property. Params: assetPath, graphName? (default AnimGraph), nodeClass? (substring filter, e.g. 'PoseDriver') (#657)\n- compare_curves_to_morph_targets: Compare an AnimSequence/PoseAsset's curve names against a SkeletalMesh's morph target names. Returns curves[], morphTargets[], matched[], curvesWithoutMorph[], morphsWithoutCurve[] - verify authored curves drive morphs without Python. Params: animPath (AnimSequence or PoseAsset), skeletalMeshPath (#656)\n- set_montage_sequence: Replace the animation sequence in a montage slot. With segmentIndex, replaces only that one segment; without it, replaces every segment in the slot. Params: assetPath, animSequencePath, slotIndex? (default 0), segmentIndex? (#626)\n- set_montage_properties: Set montage properties. Params: assetPath, sequenceLength?, rateScale?, blendIn?, blendOut?\n- create_state_machine: Create state machine in AnimBP. Params: assetPath, name?, graphName?\n- add_state: Add state to a state machine. Params: assetPath, stateMachineName, stateName\n- add_transition: Add directed transition between states. Params: assetPath, stateMachineName, fromState, toState\n- set_state_animation: Assign anim asset to state. Params: assetPath, stateMachineName, stateName, animAssetPath\n- set_transition_blend: Set blend type/duration on transition. Params: assetPath, stateMachineName, fromState, toState, blendDuration?, blendLogic?\n- set_transition_condition: Set a transition's 'can enter transition' condition from a bool variable, keyed by transition (not graph name - every rule graph is named 'Transition' so blueprint graph tools can only reach the first). Wires VariableGet(bool) -> bCanEnterTransition, replacing any prior condition. Identify the transition by transitionGuid (from add_transition/read_state_machine) OR fromState+toState. Params: assetPath, stateMachineName, variableName (existing bool var), transitionGuid? OR fromState?+toState?, negate? (default false) (#707)\n- read_state_machine: Read state machine topology. Params: assetPath, stateMachineName\n- read_anim_graph: Read AnimBP AnimGraph nodes with properties & pins. Params: assetPath, graphName?\n- add_curve: Add float curve to AnimSequence. Params: assetPath, curveName, curveType?\n- set_anim_curve_keys: Set float-curve key VALUES on an AnimSequence (add_curve only creates an empty named curve - it cannot set keyframe values). Adds the curve if missing, then replaces its keys. Use for authoring Distance/Speed/any float curve directly. Params: assetPath, curveName, keys ([{time, value, interp?('linear'|'constant'|'cubic')}]), interpolation? (default 'linear', applied to keys without their own interp) (#712)\n- apply_animation_modifier: Instantiate a UAnimationModifier subclass and run it on an AnimSequence. Headline use: modifierClass='DistanceCurveModifier' bakes a Distance curve from the clip's root motion for distance matching (needs root motion baked first - see bake_root_motion_from_bone). Registers the modifier on the sequence so it re-applies on reimport. props sets the modifier's EditAnywhere fields (e.g. DistanceCurveModifier: {CurveName, Axis:'XY'|'X'|..., bStopAtEnd, StopSpeedThreshold, SampleRate}). Note: DistanceCurveModifier ships in the 'Animation Locomotion Library' plugin (off by default) - enable it first. Params: assetPath, modifierClass (short name or /Script path), props? (#712)\n- set_montage_slot: Set slot name on a montage track. Params: assetPath, slotName, trackIndex?\n- add_montage_section: Add composite section to montage. Pass segmentIndex (with slotName or slotIndex) to anchor the section to a specific segment: its startTime is taken from that segment and it stays linked, so inserting a segment ahead of it moves the marker with its animation. Without segmentIndex the section is a bare absolute-time marker. Params: assetPath, sectionName, startTime?, linkedSection?, segmentIndex?, slotName?, slotIndex? (#826)\n- add_montage_segment: Append (or insert) an animation segment into a montage slot's anim track. This is the only way to get more than one animation into a montage: create_montage builds exactly one segment, set_montage_sequence replaces rather than appends, and add_montage_section only writes a time marker with no animation behind it. Creates the named slot when it does not exist. Validates that the source shares the montage's skeleton and matches the track's additive type, then relays out the segments, refreshes linked sections and notifies, and rewrites the montage length. Params: assetPath, animSequencePath, slotName? (created if absent), slotIndex? (default 0, used when slotName is omitted), startPos? (trim into the source, default 0), endPos? (default source play length), playRate? (default 1, negative reverses), loopCount? (default 1), insertIndex? (default appends) (#826)\n- remove_montage_segment: Remove a segment from a montage slot by index, then relay out the remaining segments and rewrite the montage length. Idempotent: alreadyDeleted=true when the slot already holds no segments. Params: assetPath, segmentIndex, slotName?, slotIndex? (default 0) (#826)\n- list_montage_segments: List every slot's segments on a montage so a caller can address them by index: animation path, startPos/endPos trim, playRate, loopCount, track position and length per segment, plus the sections with the slot and segment each one links to. Params: assetPath, slotName? (filter to one slot) (#826)\n- create_ik_rig: Create IKRigDefinition asset, optionally with retargetRoot + chains[]. Params: name, skeletalMeshPath, packagePath?, retargetRoot?, chains?: [{name, startBone, endBone, goal?}]\n- read_ik_rig: Read IK Rig chains, solvers, skeleton. Params: assetPath\n- list_control_rig_variables: List ControlRig variables and hierarchy. Params: assetPath\n- read_control_rig_graph: Read a Control Rig's RigVM models: every graph with its nodes (name, node path, class), each node's pins (name, pin path, cppType, direction, execute flag, default value, nested sub-pins) and the links between them, plus full member-variable metadata (type, subtype, array-ness, default, public/read-only). list_control_rig_variables only ever reported a node COUNT, which is not enough to verify solver wiring (#774). Params: assetPath, graphName? (substring filter), includePins? (default true), includeDefaults? (default true), includeLinks? (default true), limit? (nodes per graph, default 200)\n- read_control_rig_hierarchy: Read a Control Rig's per-element hierarchy metadata: each element's name, type (Bone|Control|Null|Curve...), index, and parent. Params: assetPath (#619)\n- set_root_motion: Set root motion settings on AnimSequence. Params: assetPath, enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?\n- add_virtual_bone: Add virtual bone. Params: skeletonPath, sourceBone, targetBone\n- remove_virtual_bone: Remove virtual bone. Params: skeletonPath, virtualBoneName\n- create_composite: Create AnimComposite. Params: name, skeletonPath, packagePath?\n- list_modifiers: List applied animation modifiers. Params: assetPath\n- create_ik_retargeter: Create IKRetargeter asset and (default) initialize the UE 5.7 ops stack: assigns sourceRig+targetRig to all ops, runs AutoMapChains. Returns chainsMapped count. Params: name, packagePath?, sourceRig?, targetRig?, autoMapChains? (default true) (#246)\n- read_ik_retargeter: Read IKRetargeter: source/target rigs and chain mappings. Params: assetPath (#246)\n- set_ik_rig_mesh: Set the preview/source skeletal mesh on an EXISTING IK Rig. Params: rigPath, meshPath (#701)\n- set_ik_retargeter_rig: Set the source or target IK Rig on an EXISTING IK Retargeter. Params: retargeterPath, rigPath, side? (source|target, default target) (#703)\n- auto_align_retarget_pose: Auto-align all bones of the source/target retarget pose (chain-to-chain) - fixes a retargeter that outputs a static reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- reset_retarget_pose: Reset the current retarget pose (all bones) to the reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- batch_retarget_animations: Bake a set of source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget). Params: retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (#701)\n- set_anim_blueprint_skeleton: Set target skeleton on AnimBP. Params: assetPath, skeletonPath\n- read_bone_track: Read bone transform samples from AnimSequence. Params: assetPath, boneName, frames?: [int]\n- create_pose_search_database: Create a PoseSearchDatabase asset (motion matching). Params: name, packagePath?, schemaPath?\n- set_pose_search_schema: Set the Schema on an existing PoseSearchDatabase. Params: assetPath, schemaPath\n- add_pose_search_sequence: Append an AnimSequence/AnimComposite/AnimMontage/BlendSpace to a PoseSearchDatabase, with optional per-clip flags. Params: assetPath, sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled? (#684)\n- set_pose_search_clips: Author the whole clip list of a PoseSearchDatabase in one call (the 'duplicate a stock PSD, swap its clips' pipeline step). Replaces the list by default. Each clip carries per-entry flags. Params: assetPath, clips ([{sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled?}] - a bare string path also works), clearExisting? (default true). Follow with build_pose_search_index (#684)\n- build_pose_search_index: Build (or rebuild) the search index. Params: assetPath, wait? (default true)\n- read_pose_search_database: Inspect a PoseSearchDatabase: schema, animation entries, cost biases, tags. Params: assetPath\n- set_pose_search_database_settings: Tune a PoseSearchDatabase: cost biases, KD-tree neighbours, search mode, PCA components, normalization set. Params: assetPath, continuingPoseCostBias?, baseCostBias?, loopingCostBias?, kdTreeQueryNumNeighbors?, numberOfPrincipalComponents?, poseSearchMode? ('bruteforce'|'pcakdtree'|'vptree'|'eventonly'), normalizationSetPath? (motion matching)\n- create_pose_search_schema: Create a PoseSearchSchema (the feature definition a database indexes against). Binds a skeleton (and optional mirror table) and, by default, adds Trajectory+Pose default channels so the schema is immediately buildable. Refine with add_pose_search_schema_*_channel. Params: name, skeletonPath, packagePath?, mirrorDataTablePath?, sampleRate?, addDefaultChannels? (default true) (motion matching)\n- add_pose_search_schema_pose_channel: Add a Pose feature channel to a schema (samples named bones for velocity/position/rotation/phase). Params: schemaPath, bones ([{bone, flags?:['velocity','position','rotation','phase'], weight?}] - a bare bone-name string defaults to position), weight? (motion matching)\n- add_pose_search_schema_trajectory_channel: Add a Trajectory feature channel to a schema (past/future motion samples). Params: schemaPath, samples ([{offset (seconds; negative=history, positive=prediction), flags?:['position','velocity','facingDirection','velocityDirection', ...XY variants], weight?}]), weight? (motion matching)\n- read_pose_search_schema: Inspect a PoseSearchSchema: skeleton(s), mirror table, sample rate, feature channels. Params: schemaPath (motion matching)\n- create_mirror_data_table: Create a MirrorDataTable for a skeleton (needed for mirrored poses in motion matching / mirror nodes). Auto-derives bone-pair rows from find/replace expressions (defaults to UE mannequin _l/_r suffix swap). Params: name, skeletonPath, packagePath?, expressions? ([{find, replace, method?:'suffix'|'prefix'|'regex'}]), mirrorAxis? (X|Y|Z, default X), mirrorRootMotion? (default true)\n- read_mirror_data_table: Inspect a MirrorDataTable: skeleton and bone-pair rows (name -> mirroredName). Params: assetPath (motion matching)\n- create_pose_search_normalization_set: Create a PoseSearchNormalizationSet grouping databases so they normalize their cost space together (consistent blending across a locomotion set). Assign it via set_pose_search_database_settings(normalizationSetPath). Params: name, packagePath?, databases? ([PoseSearchDatabase paths]) (motion matching)\n- add_motion_matching_node: Add a Motion Matching node to an AnimBP AnimGraph and point it at a PoseSearchDatabase (the runtime node that searches the database each frame). Connects its output to the Output Pose by default. For chooser-driven database selection, bind an anim-node function that calls SetDatabasesToSearch. Params: assetPath (AnimBP), databasePath, graphName? (default AnimGraph), connectToOutput? (default true), blendTime? (motion matching)\n- add_pose_history_node: Add a Pose History (PoseSearchHistoryCollector) node to an AnimBP AnimGraph - the Motion Matching node needs it in the graph to query pose/trajectory history. Defaults to self-generated trajectory (no external trajectory pin needed) and inserts itself into the pose chain feeding the Output Pose. Params: assetPath (AnimBP), graphName? (default AnimGraph), poseCount?, samplingInterval?, generateTrajectory? (default true), trajectoryHistoryCount?, trajectoryPredictionCount?, insertBeforeOutput? (default true) (motion matching)\n- set_motion_matching_chooser: Drive the Motion Matching node's Database from a ChooserTable so the database is selected at runtime by character state. Wires a thread-safe EvaluateChooser (result typed to PoseSearchDatabase) into the MM node's Database pin. contextSource selects what the chooser reads its columns from: 'self' (default, the anim instance - choosers branching on AnimBP variables) or 'pawn' (the owning pawn via TryGetPawnOwner - choosers branching on character/pawn state). Params: assetPath (AnimBP), chooserPath (ChooserTable), graphName? (default AnimGraph), contextSource? ('self'|'pawn') (motion matching)\n- add_sequence_evaluator: Add a Sequence Evaluator node (explicit-time player) to an AnimBP graph - the node distance matching drives by setting its ExplicitTime each frame. graphName can be the top-level AnimGraph or a state's inner graph (pass the state name). Defaults bTeleportToExplicitTime=false so time advances and root motion extracts. Connects to the Output Pose by default. Returns nodeGuid for bind_anim_node_function. Params: assetPath (AnimBP), sequencePath? (AnimSequence to evaluate), graphName? (default AnimGraph), explicitTime?, shouldLoop?, teleportToExplicitTime? (default false), connectToOutput? (default true) (#713)\n- bind_anim_node_function: Bind a thread-safe anim-node function to an anim graph node's update slot - the mechanism distance matching uses to advance a Sequence Evaluator's explicit time each frame (function calls AnimDistanceMatchingLibrary::DistanceMatchToTarget / AdvanceTimeByDistanceMatching). The function must already exist on the AnimBP (create it as a BlueprintThreadSafe function first). Identify the node by nodeGuid (from add_sequence_evaluator / add_*_node). Params: assetPath (AnimBP), nodeGuid, functionName, graphName? (default AnimGraph), binding? ('update' (default)|'becomeRelevant'|'initialUpdate') (#713)\n- set_sequence_properties: Batch-set properties on AnimSequence assets. If a path is a Montage and resolveFromMontages is true (default), resolves to its first AnimSequence. Params: assetPaths[], properties{enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?}, resolveFromMontages?\n- bake_root_motion_from_bone: Bake delta translation from a source bone (e.g. pelvis) onto the root bone across the whole sequence; compensates the source bone so world-space position is unchanged. Params: assetPath, sourceBone, rootBone? (default 'root'), axes? (default ['x','y']), interpolation? ('linear'|'per_frame', default 'linear')\n- get_bone_transform: Read a bone or socket transform on a live actor's SkeletalMeshComponent. Wraps GetBoneTransform / GetSocketTransform. Params: actorLabel, boneName (or socket name), componentName? (default: CharacterMesh0 / Mesh / first SK component), world? (auto|pie|game|editor, default auto), space? (world|component|local, default world). Returns location, rotation, scale (#420)\n- list_bones: List bones in a live actor's SkeletalMeshComponent ref skeleton (name, index, parent). Params: actorLabel, componentName?, world? (auto|pie|game|editor, default auto) (#420)\n- rebind_leader_pose: Re-bind every secondary SkeletalMeshComponent on an actor to a body component (default CharacterMesh0 / Mesh). One-call fix for the 'character explodes after rotating the actor' failure mode. Params: actorLabel, bodyComponent? (#419)\n- preview_animation: Toggle bUpdateAnimationInEditor + VisibilityBasedAnimTickOption=AlwaysTickPoseAndRefreshBones on every SkeletalMeshComponent of an actor. Bypasses the 'cannot be edited on templates' guard for level instances. Params: actorLabel, enabled (#419/#420)\n\nEpic 5.8 toolset actions (341): the epic_* actions above wrap Unreal's native ToolsetRegistry tools for this domain. Pass tool arguments via 'input'. A top-level parameter named by the wrapped tool's own schema is folded into 'input' for you, as is this category's canonical asset path when the tool takes a single asset reference. A call that is still missing a required argument is refused with the exact shape to send, instead of being dispatched (#798).", + "description": "Animation assets, skeletons, montages, blendspaces, anim blueprints, physics assets.\n\nActions:\n- read_anim_blueprint: Read AnimBP structure. Params: assetPath\n- read_montage: Read montage. Params: assetPath\n- read_sequence: Read anim sequence. Params: assetPath\n- scan_animation_tracks: Scan AnimSequence bone-track counts. Params: directory?, recursive?, assetPaths?, skeletonPath?, targetTrackCount?, includeTrackNames?\n- read_blendspace: Read blendspace. Params: assetPath\n- add_blend_sample: Append a sample to a BlendSpace. Params: assetPath, animation (AnimSequence path), position {x,y} (or flat x,y) (#248)\n- set_blend_sample: Move an existing BlendSpace sample or swap its animation. Params: assetPath, sampleIndex, position? {x,y} (or flat x,y), animation? (#272)\n- list: List anim assets. Params: directory?, recursive?\n- create_montage: Create montage. Params: animSequencePath, name?, packagePath?\n- author_montages_batch: Batch-author montages in one call: idempotent create, slot name, blend/rate/length properties, sections and notifies, then save. Every item reports success plus the failing stage (validate|create|slot|properties|sections|notifies|save) and error, so one bad item does not hide the rest. Newly created montages come back as a delete_asset_batch rollback. Each montage still holds the single segment create_montage builds. Params: items[] (each: name, animSequencePath, packagePath?, onConflict?, slotName?, trackIndex?, rateScale?, blendIn?, blendOut?, sequenceLength?, sections? [{sectionName, startTime?, linkedSection?}], notifies? [{notifyName, triggerTime, notifyClass?, properties?}])\n- create_anim_blueprint: Create AnimBP. Params: skeletonPath, name?, packagePath?, parentClass?\n- create_blendspace: Create blendspace (2D). Params: skeletonPath, name?, packagePath?, axisHorizontal?, axisVertical?\n- create_blendspace_1d: Create BlendSpace1D. Params: skeletonPath, name?, packagePath?, axisName? (default Speed), axisMin?, axisMax?, gridNum? (#459)\n- populate_blendspace: One-call axis params + samples authoring for BlendSpace 1D/2D. Params: assetPath, axis? ({name?, min?, max?, gridNum?}) for axis 0, blendspaceAxes? (per-axis array), axisHorizontal?/axisVertical? + horizontalMin/horizontalMax/verticalMin/verticalMax/gridNumHorizontal/gridNumVertical (back-compat), samples ([{animationPath, x, y?}]), clearExisting? (default true) (#459)\n- add_notify: Add notify. For PlayMontageNotify the notifyName is also written onto the spawned notify object so OnPlayMontageNotifyBegin broadcasts it (not 'None'), and montage branching-point markers refresh (#528). notifyProperties writes EditAnywhere fields onto the spawned notify object and therefore requires a notifyClass that resolves. Params: assetPath, notifyName, triggerTime, notifyClass?, notifyProperties?\n- remove_notify: Remove notify(s) by name and/or class. Pass at least one of notifyName/notifyClass; both filters AND. Idempotent: alreadyDeleted=true if no match. Params: assetPath, notifyName?, notifyClass? (#471)\n- get_skeleton_info: Read skeleton. Params: assetPath\n- list_sockets: List sockets. Params: assetPath\n- list_skeletal_meshes: List skeletal meshes. Params: directory?, recursive?\n- get_physics_asset: Read physics asset. Params: assetPath\n- create_sequence: Create blank AnimSequence. Params: name, skeletonPath, packagePath?, numFrames?, frameRate?\n- set_bone_keyframes: Set bone transform keyframes. Params: assetPath, boneName, keyframes\n- bake_keyframes_batch: Bake per-bone keyframe arrays for many bones into an AnimSequence in one call. Auto-creates each bone track first (set_bone_keyframes silently leaves a T-pose if the track is missing), wraps the batch in one transaction, and raises if any bone fails instead of reporting hollow success (#540). Params: assetPath, tracks ([{bone, keyframes:[{location,rotation{x,y,z,w},scale?}]}]), save? (default true)\n- get_bone_transforms: Read reference pose transforms for one, many, or ALL bones. Omit boneNames to return every bone with index/parentIndex/location/rotation/scale. With boneNames, returns only the named bones. Params: skeletonPath, boneNames? (omit = all bones), space? ('local' default, or 'component' for composed parent-chain transforms - retarget-chain / anatomical-scale work) (#245)\n- inspect_anim_nodes: Deep-dump the FAnimNode_* struct of anim graph nodes (PoseDriver PoseTargets/PoseAsset/RBF params/source bones, etc.) that read_anim_graph omits because it skips the 'Node' property. Params: assetPath, graphName? (default AnimGraph), nodeClass? (substring filter, e.g. 'PoseDriver') (#657)\n- compare_curves_to_morph_targets: Compare an AnimSequence/PoseAsset's curve names against a SkeletalMesh's morph target names. Returns curves[], morphTargets[], matched[], curvesWithoutMorph[], morphsWithoutCurve[] - verify authored curves drive morphs without Python. Params: animPath (AnimSequence or PoseAsset), skeletalMeshPath (#656)\n- set_montage_sequence: Replace the animation sequence in a montage slot. With segmentIndex, replaces only that one segment; without it, replaces every segment in the slot. Params: assetPath, animSequencePath, slotIndex? (default 0), segmentIndex? (#626)\n- set_montage_properties: Set montage properties. Params: assetPath, sequenceLength?, rateScale?, blendIn?, blendOut?\n- create_state_machine: Create state machine in AnimBP. Params: assetPath, name?, graphName?\n- add_state: Add state to a state machine. Params: assetPath, stateMachineName, stateName\n- add_transition: Add directed transition between states. Params: assetPath, stateMachineName, fromState, toState\n- set_state_animation: Assign anim asset to state. Params: assetPath, stateMachineName, stateName, animAssetPath\n- set_transition_blend: Set blend type/duration on transition. Params: assetPath, stateMachineName, fromState, toState, blendDuration?, blendLogic?\n- set_transition_condition: Set a transition's 'can enter transition' condition from a bool variable, keyed by transition (not graph name - every rule graph is named 'Transition' so blueprint graph tools can only reach the first). Wires VariableGet(bool) -> bCanEnterTransition, replacing any prior condition. Identify the transition by transitionGuid (from add_transition/read_state_machine) OR fromState+toState. Params: assetPath, stateMachineName, variableName (existing bool var), transitionGuid? OR fromState?+toState?, negate? (default false) (#707)\n- read_state_machine: Read state machine topology. Params: assetPath, stateMachineName\n- read_anim_graph: Read AnimBP AnimGraph nodes with properties & pins. Params: assetPath, graphName?\n- add_curve: Add float curve to AnimSequence. Params: assetPath, curveName, curveType?\n- set_anim_curve_keys: Set float-curve key VALUES on an AnimSequence (add_curve only creates an empty named curve - it cannot set keyframe values). Adds the curve if missing, then replaces its keys. Use for authoring Distance/Speed/any float curve directly. Params: assetPath, curveName, keys ([{time, value, interp?('linear'|'constant'|'cubic')}]), interpolation? (default 'linear', applied to keys without their own interp) (#712)\n- apply_animation_modifier: Instantiate a UAnimationModifier subclass and run it on an AnimSequence. Headline use: modifierClass='DistanceCurveModifier' bakes a Distance curve from the clip's root motion for distance matching (needs root motion baked first - see bake_root_motion_from_bone). Registers the modifier on the sequence so it re-applies on reimport. props sets the modifier's EditAnywhere fields (e.g. DistanceCurveModifier: {CurveName, Axis:'XY'|'X'|..., bStopAtEnd, StopSpeedThreshold, SampleRate}). Note: DistanceCurveModifier ships in the 'Animation Locomotion Library' plugin (off by default) - enable it first. Params: assetPath, modifierClass (short name or /Script path), props? (#712)\n- set_montage_slot: Set slot name on a montage track. Params: assetPath, slotName, trackIndex?\n- add_montage_section: Add composite section to montage. Pass segmentIndex (with slotName or slotIndex) to anchor the section to a specific segment: its startTime is taken from that segment and it stays linked, so inserting a segment ahead of it moves the marker with its animation. Without segmentIndex the section is a bare absolute-time marker. Params: assetPath, sectionName, startTime?, linkedSection?, segmentIndex?, slotName?, slotIndex? (#826)\n- add_montage_segment: Append (or insert) an animation segment into a montage slot's anim track. This is the only way to get more than one animation into a montage: create_montage builds exactly one segment, set_montage_sequence replaces rather than appends, and add_montage_section only writes a time marker with no animation behind it. Creates the named slot when it does not exist. Validates that the source shares the montage's skeleton and matches the track's additive type, then relays out the segments, refreshes linked sections and notifies, and rewrites the montage length. Params: assetPath, animSequencePath, slotName? (created if absent), slotIndex? (default 0, used when slotName is omitted), startPos? (trim into the source, default 0), endPos? (default source play length), playRate? (default 1, negative reverses), loopCount? (default 1), insertIndex? (default appends) (#826)\n- remove_montage_segment: Remove a segment from a montage slot by index, then relay out the remaining segments and rewrite the montage length. Idempotent: alreadyDeleted=true when the slot already holds no segments. Params: assetPath, segmentIndex, slotName?, slotIndex? (default 0) (#826)\n- list_montage_segments: List every slot's segments on a montage so a caller can address them by index: animation path, startPos/endPos trim, playRate, loopCount, track position and length per segment, plus the sections with the slot and segment each one links to. Params: assetPath, slotName? (filter to one slot) (#826)\n- create_ik_rig: Create IKRigDefinition asset, optionally with retargetRoot + chains[]. Params: name, skeletalMeshPath, packagePath?, retargetRoot?, chains?: [{name, startBone, endBone, goal?}]\n- read_ik_rig: Read an IK Rig's preview mesh, skeleton roots/bones, ancestry-validated chains and goal assignments, concrete goals, exclusions, and structured solver/FBIK effector state. Params: assetPath\n- configure_ik_rig: UE 5.8 only. Author an existing IK Rig through UIKRigController with strict bone, ancestry, goal, and setting validation, native readback, one transaction, and checked save; older engines return unsupported_engine_version. autoSetup='retarget' installs the native retarget definition; 'full_body' installs the retarget definition then Full Body IK before requested desired-state upserts. Params: rigPath, autoSetup? ('retarget'|'full_body'), retargetRoot?, rootMotionBone?, chains?: [{name,startBone,endBone,goal?}], fullBodyIK?: {solverIndex?,rootBone,enabled?,goals:[{name,bone,positionAlpha?,rotationAlpha?,chainDepth?,strengthAlpha?,pullChainAlpha?,pinRotation?}]}, exclusions?: [{bone,excluded}].\n- list_control_rig_variables: List ControlRig variables and hierarchy. Params: assetPath\n- read_control_rig_graph: Read a Control Rig's RigVM models: every graph with its nodes (name, node path, class), each node's pins (name, pin path, cppType, direction, execute flag, default value, nested sub-pins) and the links between them, plus full member-variable metadata (type, subtype, array-ness, default, public/read-only). list_control_rig_variables only ever reported a node COUNT, which is not enough to verify solver wiring (#774). Params: assetPath, graphName? (substring filter), includePins? (default true), includeDefaults? (default true), includeLinks? (default true), limit? (nodes per graph, default 200)\n- read_control_rig_hierarchy: Read a Control Rig's per-element hierarchy metadata: each element's name, type (Bone|Control|Null|Curve...), index, and parent. Params: assetPath (#619)\n- begin_control_rig_edit: UE 5.8 only. Create a Sequencer Control Rig editing session over a source AnimSequence; native returns unsupported_engine_version on older engines. Baseline first: before this call, reuse or create a Control Rig for the target character, bind/import the exact target skeleton, add the intended controls, author Forward Solve, add Backward/Inverse Solve, verify it with read_control_rig_hierarchy/read_control_rig_graph, and pass an unchanged source round-trip. For a new baseline, the bundled Epic 5.8 controlrig actions include epic_create, epic_import_bones_from_asset, epic_add_control, and epic_add_backward_solve_graph; epic_create alone is not a usable rig. There is no silent fallback to raw bone-key authoring. rigMode='fk' uses UFKControlRig only when generated FK controls are sufficient; rigMode='asset' requires the verified controlRigPath and rejects rigs without inverse execution. bindingTag is the stable natural key for replay. onConflict is skip|error (default error); existing sessions are never modified. layered defaults false. startFrame is inclusive and endFrame is exclusive. Params: sequencePath, skeletalMeshPath, sourceAnimationPath, rigMode ('fk'|'asset'), controlRigPath?, layered?, startFrame?, endFrame?, displayRate?, bindingTag?, onConflict?. Returns the resolved bindingTag/binding GUID, rig, frame range, controls and created/existed status.\n- read_control_rig_edit: UE 5.8 only. Read transform, bool, float/scale-float, and integer/enum controls from a Control Rig editing session without changing editor state; native returns unsupported_engine_version on older engines and has no silent fallback. Params: sequencePath, bindingTag, controlNames?, frames?, space? ('local'|'global'). Scalar samples return value instead of transform. Control metadata includes native controlType, animatable, and enum path/options where applicable. Returns session identity, layered mode, range/rate, filtered control metadata, and requested frame samples.\n- apply_control_rig_edits: UE 5.8 only. Apply typed Control Rig edits in one transaction; native returns unsupported_engine_version on older engines. There is no silent fallback to raw bone tracks. set_keys writes strictly ordered full per-frame transforms from normalized quaternions and preserves shortest-arc quaternion continuity. A set operation writes one full absolute transform at frame or frames. An offset operation applies translation/rotation/scale deltas across an inclusive frame range with optional edge blends. contact_lock densely constrains a translatable driver control, or an optional driven bone/socket reference, to a fixed component-space target with smooth edge blends and optional pole/control stabilization. Driver and stabilizer keys are read back transactionally. A drivenReference contact returns verification='bake_and_analyze_required'; bake it and analyze every constrained frame before accepting the bone/socket result. set_bool, set_float, and set_int key matching scalar controls; enum controls use set_int with one of the integer values reported in enumOptions. Params: sequencePath, bindingTag, operations[] where set_keys={op:'set_keys',control,keys:[{frame,transform:{translation,rotationQuaternion,scale}}],space?}, set={op:'set',control,frame|frames,transform:{translation,rotationDegrees,scale},space?}, offset={op:'offset',control,startFrame,endFrame,translationCm?,rotationDegrees?,scaleMultiplier?,space?,blendInFrames?,blendOutFrames?}, contact_lock={op:'contact_lock',control,drivenReference?,startFrame,endFrame,target:{translation,rotationQuaternion?},blendInFrames?,blendOutFrames?,stabilizeControls?,positionToleranceCm?,rotationToleranceDegrees?}, set_bool={op:'set_bool',control,frame|frames,value}, set_float={op:'set_float',control,frame|frames,value}, or set_int={op:'set_int',control,frame|frames,value}. Sequencer's current interpolation mode is retained. Returns per-operation counts, affected controls/frames, and contactQa summaries; a failed key/readback batch is undone.\n- bake_control_rig_edit: UE 5.8 only. Bake the evaluated Control Rig session to a new AnimSequence asset; native returns unsupported_engine_version on older engines and has no raw-track fallback. The source LevelSequence remains unchanged. outputAssetPath is the output natural key; onConflict is skip|error (default error), never overwrite. Key reduction and Sequencer links are not supported yet, so reduceKeys/createLink must be false or omitted. Params: sequencePath, bindingTag, outputAssetPath, frameRate?, reduceKeys?, tolerance?, createLink?, onConflict?. Returns output asset metadata, frame/rate counts, status, and delete-created-asset rollback.\n- analyze_animation: Cross-version, data-driven AnimSequence inspection using the native animation APIs available in the compiled engine. Samples an AnimSequence and reports deterministic numeric motion diagnostics without Python or viewport inference. Params: assetPath (required AnimSequence), skeletalMeshPath?, boneNames?, frames?, sampleRate?, loop?, outputDirectory? (must resolve under Project/Saved/Codex/AnimationQA and must not already contain artifacts). Returns source/rate/range metadata, sampled local/component transforms, root-motion and continuity metrics, and any written analysis artifacts.\n- set_root_motion: Set root motion settings on AnimSequence. Params: assetPath, enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?\n- add_virtual_bone: Add virtual bone. Params: skeletonPath, sourceBone, targetBone\n- remove_virtual_bone: Remove virtual bone. Params: skeletonPath, virtualBoneName\n- create_composite: Create AnimComposite. Params: name, skeletonPath, packagePath?\n- list_modifiers: List applied animation modifiers. Params: assetPath\n- create_ik_retargeter: Create IKRetargeter asset and (default) initialize the UE 5.7 ops stack: assigns sourceRig+targetRig to all ops, runs AutoMapChains. Returns chainsMapped count. Params: name, packagePath?, sourceRig?, targetRig?, autoMapChains? (default true) (#246)\n- read_ik_retargeter: Read an IK Retargeter's source/target rigs and preview meshes, flattened and per-op chain mappings, typed op stack, and all named/current pose offsets when the compiled engine exposes them. Params: assetPath (#246)\n- configure_ik_retargeter: UE 5.8 only. Configure an existing IK Retargeter through UIKRetargeterController with the correct default-op and per-op rig assignment order, auto/manual chain mappings, named pose authoring, processor validation, native readback, transaction rollback, and checked save; older engines return unsupported_engine_version. Whole-pose auto-align resets that pose first: create a new pose or pass pose.reset=true to acknowledge replacement, then manual offsets are applied. Params: retargeterPath, sourceRig?, targetRig?, sourcePreviewMesh?, targetPreviewMesh?, ensureDefaultOps? (default true), autoMapMode? ('exact'|'fuzzy'|'clear'), forceRemap? (default false), chainMappings?: [{targetChain,sourceChain?:string|null}], pose?: {side,name,create?,reset?,autoAlign?,bones?,rotationOffsets?:[{bone,rotationQuaternion}],rootOffsetZ?,snapBoneToGround?}.\n- set_ik_rig_mesh: Set the preview/source skeletal mesh on an EXISTING IK Rig. Params: rigPath, meshPath (#701)\n- set_ik_retargeter_rig: Set the source or target IK Rig on an EXISTING IK Retargeter. Params: retargeterPath, rigPath, side? (source|target, default target) (#703)\n- auto_align_retarget_pose: Auto-align all bones of the source/target retarget pose (chain-to-chain) - fixes a retargeter that outputs a static reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- reset_retarget_pose: Reset the current retarget pose (all bones) to the reference pose. Params: retargeterPath, side? (source|target, default target) (#701)\n- batch_retarget_animations: Bake validated source AnimSequences onto the target skeleton through an IK Retargeter (RunBatchRetarget), save every output, and roll back newly created outputs if the batch is incomplete or unsavable. Overwrite is rejected. Returns mapping completeness and every unmapped target chain so partial retargets are explicit; pass requireCompleteMapping=true only when the target should have no intentional extra chains. Params: retargeterPath, sourceMesh, targetMesh, animPaths[], outputPath? (default: alongside source), prefix?, suffix? (default _Retargeted), overwrite? (must be false), requireCompleteMapping? (default false) (#701)\n- set_anim_blueprint_skeleton: Set target skeleton on AnimBP. Params: assetPath, skeletonPath\n- read_bone_track: Read bone transform samples from AnimSequence. Params: assetPath, boneName, frames?: [int]\n- create_pose_search_database: Create a PoseSearchDatabase asset (motion matching). Params: name, packagePath?, schemaPath?\n- set_pose_search_schema: Set the Schema on an existing PoseSearchDatabase. Params: assetPath, schemaPath\n- add_pose_search_sequence: Append an AnimSequence/AnimComposite/AnimMontage/BlendSpace to a PoseSearchDatabase, with optional per-clip flags. Params: assetPath, sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled? (#684)\n- set_pose_search_clips: Author the whole clip list of a PoseSearchDatabase in one call (the 'duplicate a stock PSD, swap its clips' pipeline step). Replaces the list by default. Each clip carries per-entry flags. Params: assetPath, clips ([{sequencePath, mirror? ('original'|'mirrored'|'both'), disableReselection?, sampleStart?, sampleEnd?, enabled?}] - a bare string path also works), clearExisting? (default true). Follow with build_pose_search_index (#684)\n- build_pose_search_index: Build (or rebuild) the search index. Params: assetPath, wait? (default true)\n- read_pose_search_database: Inspect a PoseSearchDatabase: schema, animation entries, cost biases, tags. Params: assetPath\n- set_pose_search_database_settings: Tune a PoseSearchDatabase: cost biases, KD-tree neighbours, search mode, PCA components, normalization set. Params: assetPath, continuingPoseCostBias?, baseCostBias?, loopingCostBias?, kdTreeQueryNumNeighbors?, numberOfPrincipalComponents?, poseSearchMode? ('bruteforce'|'pcakdtree'|'vptree'|'eventonly'), normalizationSetPath? (motion matching)\n- create_pose_search_schema: Create a PoseSearchSchema (the feature definition a database indexes against). Binds a skeleton (and optional mirror table) and, by default, adds Trajectory+Pose default channels so the schema is immediately buildable. Refine with add_pose_search_schema_*_channel. Params: name, skeletonPath, packagePath?, mirrorDataTablePath?, sampleRate?, addDefaultChannels? (default true) (motion matching)\n- add_pose_search_schema_pose_channel: Add a Pose feature channel to a schema (samples named bones for velocity/position/rotation/phase). Params: schemaPath, bones ([{bone, flags?:['velocity','position','rotation','phase'], weight?}] - a bare bone-name string defaults to position), weight? (motion matching)\n- add_pose_search_schema_trajectory_channel: Add a Trajectory feature channel to a schema (past/future motion samples). Params: schemaPath, samples ([{offset (seconds; negative=history, positive=prediction), flags?:['position','velocity','facingDirection','velocityDirection', ...XY variants], weight?}]), weight? (motion matching)\n- read_pose_search_schema: Inspect a PoseSearchSchema: skeleton(s), mirror table, sample rate, feature channels. Params: schemaPath (motion matching)\n- create_mirror_data_table: Create a MirrorDataTable for a skeleton (needed for mirrored poses in motion matching / mirror nodes). Auto-derives bone-pair rows from find/replace expressions (defaults to UE mannequin _l/_r suffix swap). Params: name, skeletonPath, packagePath?, expressions? ([{find, replace, method?:'suffix'|'prefix'|'regex'}]), mirrorAxis? (X|Y|Z, default X), mirrorRootMotion? (default true)\n- read_mirror_data_table: Inspect a MirrorDataTable: skeleton and bone-pair rows (name -> mirroredName). Params: assetPath (motion matching)\n- create_pose_search_normalization_set: Create a PoseSearchNormalizationSet grouping databases so they normalize their cost space together (consistent blending across a locomotion set). Assign it via set_pose_search_database_settings(normalizationSetPath). Params: name, packagePath?, databases? ([PoseSearchDatabase paths]) (motion matching)\n- add_motion_matching_node: Add a Motion Matching node to an AnimBP AnimGraph and point it at a PoseSearchDatabase (the runtime node that searches the database each frame). Connects its output to the Output Pose by default. For chooser-driven database selection, bind an anim-node function that calls SetDatabasesToSearch. Params: assetPath (AnimBP), databasePath, graphName? (default AnimGraph), connectToOutput? (default true), blendTime? (motion matching)\n- add_pose_history_node: Add a Pose History (PoseSearchHistoryCollector) node to an AnimBP AnimGraph - the Motion Matching node needs it in the graph to query pose/trajectory history. Defaults to self-generated trajectory (no external trajectory pin needed) and inserts itself into the pose chain feeding the Output Pose. Params: assetPath (AnimBP), graphName? (default AnimGraph), poseCount?, samplingInterval?, generateTrajectory? (default true), trajectoryHistoryCount?, trajectoryPredictionCount?, insertBeforeOutput? (default true) (motion matching)\n- set_motion_matching_chooser: Drive the Motion Matching node's Database from a ChooserTable so the database is selected at runtime by character state. Wires a thread-safe EvaluateChooser (result typed to PoseSearchDatabase) into the MM node's Database pin. contextSource selects what the chooser reads its columns from: 'self' (default, the anim instance - choosers branching on AnimBP variables) or 'pawn' (the owning pawn via TryGetPawnOwner - choosers branching on character/pawn state). Params: assetPath (AnimBP), chooserPath (ChooserTable), graphName? (default AnimGraph), contextSource? ('self'|'pawn') (motion matching)\n- add_sequence_evaluator: Add a Sequence Evaluator node (explicit-time player) to an AnimBP graph - the node distance matching drives by setting its ExplicitTime each frame. graphName can be the top-level AnimGraph or a state's inner graph (pass the state name). Defaults bTeleportToExplicitTime=false so time advances and root motion extracts. Connects to the Output Pose by default. Returns nodeGuid for bind_anim_node_function. Params: assetPath (AnimBP), sequencePath? (AnimSequence to evaluate), graphName? (default AnimGraph), explicitTime?, shouldLoop?, teleportToExplicitTime? (default false), connectToOutput? (default true) (#713)\n- bind_anim_node_function: Bind a thread-safe anim-node function to an anim graph node's update slot - the mechanism distance matching uses to advance a Sequence Evaluator's explicit time each frame (function calls AnimDistanceMatchingLibrary::DistanceMatchToTarget / AdvanceTimeByDistanceMatching). The function must already exist on the AnimBP (create it as a BlueprintThreadSafe function first). Identify the node by nodeGuid (from add_sequence_evaluator / add_*_node). Params: assetPath (AnimBP), nodeGuid, functionName, graphName? (default AnimGraph), binding? ('update' (default)|'becomeRelevant'|'initialUpdate') (#713)\n- set_sequence_properties: Batch-set properties on AnimSequence assets. If a path is a Montage and resolveFromMontages is true (default), resolves to its first AnimSequence. Params: assetPaths[], properties{enableRootMotion?, forceRootLock?, useNormalizedRootMotionScale?, rootMotionRootLock?}, resolveFromMontages?\n- bake_root_motion_from_bone: Bake delta translation from a source bone (e.g. pelvis) onto the root bone across the whole sequence; compensates the source bone so world-space position is unchanged. Params: assetPath, sourceBone, rootBone? (default 'root'), axes? (default ['x','y']), interpolation? ('linear'|'per_frame', default 'linear')\n- get_bone_transform: Read a bone or socket transform on a live actor's SkeletalMeshComponent. Wraps GetBoneTransform / GetSocketTransform. Params: actorLabel, boneName (or socket name), componentName? (default: CharacterMesh0 / Mesh / first SK component), world? (auto|pie|game|editor, default auto), space? (world|component|local, default world). Returns location, rotation, scale (#420)\n- list_bones: List bones in a live actor's SkeletalMeshComponent ref skeleton (name, index, parent). Params: actorLabel, componentName?, world? (auto|pie|game|editor, default auto) (#420)\n- rebind_leader_pose: Re-bind every secondary SkeletalMeshComponent on an actor to a body component (default CharacterMesh0 / Mesh). One-call fix for the 'character explodes after rotating the actor' failure mode. Params: actorLabel, bodyComponent? (#419)\n- preview_animation: Toggle bUpdateAnimationInEditor + VisibilityBasedAnimTickOption=AlwaysTickPoseAndRefreshBones on every SkeletalMeshComponent of an actor. Bypasses the 'cannot be edited on templates' guard for level instances. Params: actorLabel, enabled (#419/#420)\n\nEpic 5.8 toolset actions (341): the epic_* actions above wrap Unreal's native ToolsetRegistry tools for this domain. Pass tool arguments via 'input'. A top-level parameter named by the wrapped tool's own schema is folded into 'input' for you, as is this category's canonical asset path when the tool takes a single asset reference. A call that is still missing a required argument is refused with the exact shape to send, instead of being dispatched (#798).", "inputSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, @@ -63,9 +63,15 @@ "list_montage_segments", "create_ik_rig", "read_ik_rig", + "configure_ik_rig", "list_control_rig_variables", "read_control_rig_graph", "read_control_rig_hierarchy", + "begin_control_rig_edit", + "read_control_rig_edit", + "apply_control_rig_edits", + "bake_control_rig_edit", + "analyze_animation", "set_root_motion", "add_virtual_bone", "remove_virtual_bone", @@ -73,6 +79,7 @@ "list_modifiers", "create_ik_retargeter", "read_ik_retargeter", + "configure_ik_retargeter", "set_ik_rig_mesh", "set_ik_retargeter_rig", "auto_align_retarget_pose", @@ -493,6 +500,23 @@ "description": "create_ik_retargeter: assign rigs to ops + AutoMapChains after creation (default true)", "type": "boolean" }, + "autoMapMode": { + "description": "configure_ik_retargeter: native chain auto-map mode", + "enum": [ + "exact", + "fuzzy", + "clear" + ], + "type": "string" + }, + "autoSetup": { + "description": "configure_ik_rig: optional native rig setup pass", + "enum": [ + "retarget", + "full_body" + ], + "type": "string" + }, "axes": { "description": "Axes to bake ('x','y','z') for bake_root_motion_from_bone", "items": { @@ -533,6 +557,11 @@ "description": "bind_anim_node_function: 'update' (default), 'becomeRelevant', or 'initialUpdate'", "type": "string" }, + "bindingTag": { + "description": "Stable Control Rig edit-session natural key used by begin/read/apply/bake.", + "minLength": 1, + "type": "string" + }, "blendDuration": { "type": "number" }, @@ -575,21 +604,54 @@ "description": "add_pose_search_schema_pose_channel: [{bone, flags?, weight?}] or bone-name strings; also add_pose_search_schema_trajectory_channel reuses 'samples'", "type": "array" }, + "chainMappings": { + "description": "configure_ik_retargeter: explicit target-to-source chain overrides; null or omitted source clears", + "items": { + "additionalProperties": false, + "properties": { + "sourceChain": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetChain": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "targetChain" + ], + "type": "object" + }, + "maxItems": 10000, + "type": "array" + }, "chains": { - "description": "IK retarget chains for create_ik_rig", + "description": "IK retarget chains for create_ik_rig or configure_ik_rig", "items": { "additionalProperties": false, "properties": { "endBone": { + "minLength": 1, "type": "string" }, "goal": { + "minLength": 1, "type": "string" }, "name": { + "minLength": 1, "type": "string" }, "startBone": { + "minLength": 1, "type": "string" } }, @@ -600,6 +662,7 @@ ], "type": "object" }, + "maxItems": 256, "type": "array" }, "chooserPath": { @@ -630,6 +693,24 @@ "description": "set_pose_search_database_settings: bias to keep playing the current clip", "type": "number" }, + "controlNames": { + "description": "read_control_rig_edit: optional controls to sample; omit to read every control.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "controlRigPath": { + "description": "begin_control_rig_edit: verified baseline ControlRigBlueprint asset path for this target character; required when rigMode='asset'. Create and validate the rig first when the project/character has none.", + "type": "string" + }, + "createLink": { + "const": false, + "description": "bake_control_rig_edit: Sequencer links are not supported yet; omit or pass false.", + "type": "boolean" + }, "curveName": { "description": "Curve name for add_curve", "type": "string" @@ -656,6 +737,11 @@ "description": "PoseSearch clip: disallow reselecting poses from the same asset", "type": "boolean" }, + "displayRate": { + "description": "begin_control_rig_edit: optional LevelSequence display rate in frames per second.", + "exclusiveMinimum": 0, + "type": "number" + }, "enableRootMotion": { "type": "boolean" }, @@ -663,10 +749,40 @@ "description": "preview_animation: toggle on/off", "type": "boolean" }, + "endFrame": { + "description": "begin_control_rig_edit: optional exclusive edit range end frame.", + "type": "integer" + }, "endPos": { "description": "add_montage_segment: trim end inside the source animation (default: source play length)", "type": "number" }, + "ensureDefaultOps": { + "description": "configure_ik_retargeter: ensure the complete UE 5.8 default operation stack; defaults true", + "type": "boolean" + }, + "exclusions": { + "description": "configure_ik_rig: desired per-bone solver exclusions", + "items": { + "additionalProperties": false, + "properties": { + "bone": { + "minLength": 1, + "type": "string" + }, + "excluded": { + "type": "boolean" + } + }, + "required": [ + "bone", + "excluded" + ], + "type": "object" + }, + "maxItems": 2048, + "type": "array" + }, "explicitTime": { "description": "add_sequence_evaluator: initial ExplicitTime", "type": "number" @@ -675,14 +791,19 @@ "description": "create_mirror_data_table: [{find, replace, method?}] find/replace bone-name rules", "type": "array" }, + "forceRemap": { + "description": "configure_ik_retargeter: replace existing mappings during auto-map; defaults false", + "type": "boolean" + }, "forceRootLock": { "type": "boolean" }, "frameRate": { + "description": "Frames per second for create_sequence or bake_control_rig_edit.", "type": "number" }, "frames": { - "description": "Specific frames to sample for read_bone_track", + "description": "Frames to read/sample. Used by read_bone_track, read_control_rig_edit, and analyze_animation.", "items": { "type": "number" }, @@ -691,6 +812,80 @@ "fromState": { "type": "string" }, + "fullBodyIK": { + "additionalProperties": false, + "description": "configure_ik_rig: Full Body IK solver and desired goal/effector settings", + "properties": { + "enabled": { + "type": "boolean" + }, + "goals": { + "items": { + "additionalProperties": false, + "properties": { + "bone": { + "minLength": 1, + "type": "string" + }, + "chainDepth": { + "minimum": 0, + "type": "integer" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "pinRotation": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "positionAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "pullChainAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rotationAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "strengthAlpha": { + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "required": [ + "name", + "bone" + ], + "type": "object" + }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "rootBone": { + "minLength": 1, + "type": "string" + }, + "solverIndex": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "rootBone", + "goals" + ], + "type": "object" + }, "functionName": { "description": "bind_anim_node_function: thread-safe anim-node function name to bind", "type": "string" @@ -946,6 +1141,10 @@ }, "type": "array" }, + "layered": { + "description": "begin_control_rig_edit: keep the source animation track active under the Control Rig layer; defaults false.", + "type": "boolean" + }, "limit": { "description": "read_control_rig_graph: max nodes reported per graph (default 200) (#774)", "type": "number" @@ -954,6 +1153,10 @@ "description": "Next section name to link to", "type": "string" }, + "loop": { + "description": "analyze_animation: include end-to-start loop continuity metrics.", + "type": "boolean" + }, "loopCount": { "description": "add_montage_segment: how many times the segment repeats (default 1)", "minimum": 1, @@ -1025,7 +1228,395 @@ "type": "number" }, "onConflict": { - "description": "Asset-creation conflict policy: skip (default) | error | overwrite", + "description": "Conflict policy. Existing asset actions use skip|error|overwrite; Control Rig begin/bake use skip|error and never overwrite.", + "type": "string" + }, + "operations": { + "description": "apply_control_rig_edits: typed transform/bool/float/int edit operations, including quaternion set_keys.", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set", + "type": "string" + }, + "space": { + "enum": [ + "local", + "global" + ], + "type": "string" + }, + "transform": { + "additionalProperties": false, + "properties": { + "rotationDegrees": { + "additionalProperties": false, + "properties": { + "pitch": { + "type": "number" + }, + "roll": { + "type": "number" + }, + "yaw": { + "type": "number" + } + }, + "required": [ + "pitch", + "yaw", + "roll" + ], + "type": "object" + }, + "scale": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + }, + "translation": { + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z" + ], + "type": "object" + } + }, + "required": [ + "translation", + "rotationDegrees", + "scale" + ], + "type": "object" + } + }, + "required": [ + "op", + "control", + "transform" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "keys": { + "items": { + "additionalProperties": false, + "properties": { + "frame": { + "type": "integer" + }, + "transform": { + "additionalProperties": false, + "properties": { + "rotationQuaternion": { + "$ref": "#/properties/pose/properties/rotationOffsets/items/properties/rotationQuaternion" + }, + "scale": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + }, + "translation": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + } + }, + "required": [ + "translation", + "rotationQuaternion", + "scale" + ], + "type": "object" + } + }, + "required": [ + "frame", + "transform" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_keys", + "type": "string" + }, + "space": { + "enum": [ + "local", + "global" + ], + "type": "string" + } + }, + "required": [ + "op", + "control", + "keys" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "blendInFrames": { + "minimum": 0, + "type": "integer" + }, + "blendOutFrames": { + "minimum": 0, + "type": "integer" + }, + "control": { + "minLength": 1, + "type": "string" + }, + "endFrame": { + "type": "integer" + }, + "op": { + "const": "offset", + "type": "string" + }, + "rotationDegrees": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/rotationDegrees" + }, + "scaleMultiplier": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + }, + "space": { + "enum": [ + "local", + "global" + ], + "type": "string" + }, + "startFrame": { + "type": "integer" + }, + "translationCm": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + } + }, + "required": [ + "op", + "control", + "startFrame", + "endFrame" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "blendInFrames": { + "minimum": 0, + "type": "integer" + }, + "blendOutFrames": { + "minimum": 0, + "type": "integer" + }, + "control": { + "minLength": 1, + "type": "string" + }, + "drivenReference": { + "minLength": 1, + "type": "string" + }, + "endFrame": { + "type": "integer" + }, + "op": { + "const": "contact_lock", + "type": "string" + }, + "positionToleranceCm": { + "exclusiveMinimum": 0, + "maximum": 100, + "type": "number" + }, + "rotationToleranceDegrees": { + "exclusiveMinimum": 0, + "maximum": 180, + "type": "number" + }, + "stabilizeControls": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 8, + "type": "array" + }, + "startFrame": { + "type": "integer" + }, + "target": { + "additionalProperties": false, + "properties": { + "rotationQuaternion": { + "$ref": "#/properties/pose/properties/rotationOffsets/items/properties/rotationQuaternion" + }, + "translation": { + "$ref": "#/properties/operations/items/anyOf/0/properties/transform/properties/translation" + } + }, + "required": [ + "translation" + ], + "type": "object" + } + }, + "required": [ + "op", + "control", + "startFrame", + "endFrame", + "target" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_bool", + "type": "string" + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "op", + "control", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_float", + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "op", + "control", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "control": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "integer" + }, + "frames": { + "items": { + "type": "integer" + }, + "minItems": 1, + "type": "array" + }, + "op": { + "const": "set_int", + "type": "string" + }, + "value": { + "type": "integer" + } + }, + "required": [ + "op", + "control", + "value" + ], + "type": "object" + } + ] + }, + "minItems": 1, + "type": "array" + }, + "outputAssetPath": { + "description": "bake_control_rig_edit: required destination AnimSequence asset path.", + "type": "string" + }, + "outputDirectory": { + "description": "analyze_animation: optional directory under Project/Saved/Codex/AnimationQA for deterministic artifacts; relative values resolve under that root.", "type": "string" }, "outputPath": { @@ -1047,6 +1638,100 @@ "description": "add_montage_segment: segment play rate, negative plays in reverse (default 1)", "type": "number" }, + "pose": { + "additionalProperties": false, + "description": "configure_ik_retargeter: named source or target pose authoring", + "properties": { + "autoAlign": { + "enum": [ + "chain_to_chain", + "mesh_to_mesh", + "local_axes", + "global_axes" + ], + "type": "string" + }, + "bones": { + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 10000, + "type": "array" + }, + "create": { + "type": "boolean" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "reset": { + "type": "boolean" + }, + "rootOffsetZ": { + "type": "number" + }, + "rotationOffsets": { + "items": { + "additionalProperties": false, + "properties": { + "bone": { + "minLength": 1, + "type": "string" + }, + "rotationQuaternion": { + "additionalProperties": false, + "properties": { + "w": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y", + "z", + "w" + ], + "type": "object" + } + }, + "required": [ + "bone", + "rotationQuaternion" + ], + "type": "object" + }, + "maxItems": 10000, + "type": "array" + }, + "side": { + "enum": [ + "source", + "target" + ], + "type": "string" + }, + "snapBoneToGround": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "side", + "name" + ], + "type": "object" + }, "poseCount": { "description": "add_pose_history_node: number of history poses to retain", "type": "number" @@ -1088,6 +1773,15 @@ "recursive": { "type": "boolean" }, + "reduceKeys": { + "const": false, + "description": "bake_control_rig_edit: key reduction is not supported yet; omit or pass false.", + "type": "boolean" + }, + "requireCompleteMapping": { + "description": "batch_retarget_animations: reject any unmapped target chain; default false", + "type": "boolean" + }, "resolveFromMontages": { "description": "Resolve AnimMontage inputs to first anim reference (default true)", "type": "boolean" @@ -1100,6 +1794,14 @@ "description": "IK Retargeter path (#701/#703)", "type": "string" }, + "rigMode": { + "description": "begin_control_rig_edit: use 'asset' with the verified baseline controlRigPath; use 'fk' only when generated raw FK controls are the intended editing surface.", + "enum": [ + "fk", + "asset" + ], + "type": "string" + }, "rigPath": { "description": "IK Rig path for set_ik_rig_mesh / set_ik_retargeter_rig (#701/#703)", "type": "string" @@ -1108,6 +1810,11 @@ "description": "Root bone name for bake_root_motion_from_bone (default 'root')", "type": "string" }, + "rootMotionBone": { + "description": "configure_ik_rig: root-motion bone name", + "minLength": 1, + "type": "string" + }, "rootMotionRootLock": { "description": "RefPose|AnimFirstFrame|Zero", "type": "string" @@ -1121,7 +1828,7 @@ "type": "number" }, "sampleRate": { - "description": "create_pose_search_schema: schema sample rate (default 30)", + "description": "Sample rate. create_pose_search_schema: schema rate (default 30); analyze_animation: optional analysis sampling rate.", "type": "number" }, "sampleStart": { @@ -1160,7 +1867,7 @@ "type": "number" }, "sequencePath": { - "description": "Animation asset path to add to a PoseSearchDatabase", + "description": "Animation path for PoseSearch graph actions, or LevelSequence path for the UE 5.8 Control Rig edit workflow.", "type": "string" }, "shouldLoop": { @@ -1172,7 +1879,7 @@ "type": "string" }, "skeletalMeshPath": { - "description": "Path to skeletal mesh for create_ik_rig / compare_curves_to_morph_targets (#656)", + "description": "SkeletalMesh asset path. Used by IK Rig creation, curve/morph comparison, Control Rig edit setup, and animation analysis.", "type": "string" }, "skeletonPath": { @@ -1185,6 +1892,10 @@ "description": "Slot name for set_montage_slot. add_montage_segment: target slot, created when absent. remove_montage_segment / list_montage_segments / add_montage_section: target slot (#826)", "type": "string" }, + "sourceAnimationPath": { + "description": "begin_control_rig_edit: source AnimSequence asset path (required).", + "type": "string" + }, "sourceBone": { "type": "string" }, @@ -1192,14 +1903,23 @@ "description": "batch_retarget_animations: source skeletal mesh (#701)", "type": "string" }, + "sourcePreviewMesh": { + "description": "configure_ik_retargeter: source preview SkeletalMesh path", + "minLength": 1, + "type": "string" + }, "sourceRig": { "description": "Source IKRig path for create_ik_retargeter", "type": "string" }, "space": { - "description": "Bone-space frame. get_bone_transforms (ref skeleton): 'local' (default) | 'component'. get_bone_transform (live actor): 'world' (default) | 'component' | 'local'", + "description": "Transform space. Control Rig edit actions: 'local'|'global'. get_bone_transforms: 'local'|'component'. get_bone_transform: 'world'|'component'|'local'.", "type": "string" }, + "startFrame": { + "description": "begin_control_rig_edit: optional inclusive edit range start frame.", + "type": "integer" + }, "startPos": { "description": "add_montage_segment: trim start inside the source animation (default 0)", "type": "number" @@ -1225,6 +1945,11 @@ "description": "batch_retarget_animations: target skeletal mesh (#701)", "type": "string" }, + "targetPreviewMesh": { + "description": "configure_ik_retargeter: target preview SkeletalMesh path", + "minLength": 1, + "type": "string" + }, "targetRig": { "description": "Target IKRig path for create_ik_retargeter", "type": "string" @@ -1240,6 +1965,11 @@ "toState": { "type": "string" }, + "tolerance": { + "description": "bake_control_rig_edit: key-reduction tolerance.", + "minimum": 0, + "type": "number" + }, "trackIndex": { "description": "Slot track index (default: 0)", "type": "number" diff --git a/tests/unit/action-class.test.ts b/tests/unit/action-class.test.ts index 5112683a..b776c8ff 100644 --- a/tests/unit/action-class.test.ts +++ b/tests/unit/action-class.test.ts @@ -73,7 +73,7 @@ describe("action classification", () => { }); it("treats an arbitrary payload as unknown, and gates it like a mutation", () => { - for (const key of ["epic.call_tool", "editor.invoke_object_function"]) { + for (const key of ["epic.call_tool", "editor.invoke_object_function", "animation.analyze_animation"]) { const cls = classifyTaskClass(key); expect(cls.class, key).toBe("unknown"); expect(requiresExplicitEditor(cls.class)).toBe(true); @@ -83,10 +83,17 @@ describe("action classification", () => { }); it("lets plain reads through", () => { - for (const key of ["project.get_status", "asset.list", "level.get_outliner", "reflection.reflect_class"]) { + for (const key of [ + "project.get_status", + "asset.list", + "level.get_outliner", + "reflection.reflect_class", + "animation.read_control_rig_edit", + ]) { expect(classifyTaskClass(key).class, key).toBe("read"); expect(requiresExplicitEditor("read")).toBe(false); } + expect(classifyTaskClass("animation.read_control_rig_edit").source).toBe("override"); }); it("reads a mutate verb anywhere in the name, not only at the front", () => { diff --git a/tests/unit/animation-control-rig-edit.test.ts b/tests/unit/animation-control-rig-edit.test.ts new file mode 100644 index 00000000..02747e54 --- /dev/null +++ b/tests/unit/animation-control-rig-edit.test.ts @@ -0,0 +1,535 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { animationTool } from "../../src/tools/animation.js"; +import type { ToolContext } from "../../src/types.js"; + +const workflowActions = [ + "begin_control_rig_edit", + "read_control_rig_edit", + "apply_control_rig_edits", + "bake_control_rig_edit", +] as const; + +describe("animation Control Rig edit workflow", () => { + it("filters read metadata without narrowing begin, skip, or unfiltered session responses", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + const readHandler = source.slice( + source.indexOf("FAnimationHandlers::ReadControlRigEdit"), + source.indexOf("FAnimationHandlers::ApplyControlRigEdits"), + ); + + expect(source).toContain("if (ControlFilter && !ControlFilter->Contains(Control->GetFName())) continue;"); + expect(readHandler).toContain("if (RequestedNames)"); + expect(readHandler).toContain("Session.ControlRig, &ControlNames"); + expect(readHandler).toContain('SetNumberField(TEXT("controlCount"), RequestedControls.Num())'); + expect(readHandler).toContain('SetArrayField(TEXT("controls"), RequestedControls)'); + expect(source.match(/ControlRigSequencerSessionJson\(Session\);/g)).toHaveLength(3); + }); + + it("keeps the source animation active when layered conversion clears baked rig keys", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain('SetBoolField(TEXT("layered"), Session.ControlRig->IsAdditive())'); + expect(source).toContain("SetControlRigLayeredMode(RigTrack, true)"); + expect(source).toContain("AnimationTrack->SetEvalDisabled(false)"); + }); + + it("keeps a support frame for the exporter's exact-end sample", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain("FFrameNumber(EndFrameExclusive + 1)"); + expect(source).toContain("RigSections[0]->SetEndFrame"); + expect(source).toContain("falling outside every section and snapping to reference pose"); + }); + + it("cancels AnimSequence RateScale so a session maps the raw timeline once", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + const nativeTest = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Tests/AnimationControlRigTimelineTests.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain("const float RawTimelinePlayRate = static_cast(1.0 / SourceRateScale)"); + expect(source).toContain("AnimationSection->Params.PlayRate = RawTimelinePlayRate"); + expect(nativeTest).toContain("SourceAnimation->RateScale = 3.06608796f"); + expect(nativeTest).toContain("for (int32 Frame = 0; Frame <= 40; ++Frame)"); + expect(nativeTest).toContain("Section->MapTimeToAnimation(FFrameTime(Frame), DisplayRate)"); + }); + + it("validates offset ranges before expanding them into frames", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + + const validation = source.indexOf("Start < RangeStart || End >= RangeEndExclusive"); + const expansion = source.indexOf("for (int64 Frame = Start; Frame <= End; ++Frame)"); + expect(validation).toBeGreaterThan(-1); + expect(expansion).toBeGreaterThan(validation); + expect(source).toContain("ControlRigSequencerMaxFrames = 100000"); + expect(source).toContain("FrameCount > ControlRigSequencerMaxFrames"); + expect(source).toContain("Control Rig edits could not be saved and were rolled back"); + expect(source).toContain("AnimSequence export completed in memory but the output asset could not be saved"); + }); + + it("keeps validation artifacts inside their native root and never overwrites a run", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain("FPaths::IsUnderDirectory(Candidate, NormalizedRoot)"); + expect(source).toContain("outputDirectory already contains animation validation artifacts"); + expect(source).toContain("Delete(*SamplesPath, false, true)"); + expect(source).toContain("MakeCompactPoseIndex(FMeshPoseBoneIndex(Index))"); + expect(source).toContain("MakeMeshPoseIndex(Index).GetInt()"); + }); + + it("reports asset-rate-scaled duration and notify timing from the native analyzer", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain("const double RateScale = static_cast(Sequence->RateScale)"); + expect(source).toContain("const double PlaybackRateMagnitude = FMath::Abs(RateScale)"); + expect(source).toContain("DurationSeconds / PlaybackRateMagnitude"); + expect(source).toContain('SetNumberField(TEXT("rawTriggerTimeSeconds"), RawTriggerTimeSeconds)'); + expect(source).toContain("RawTriggerTimeSeconds / PlaybackRateMagnitude"); + expect(source.match(/SetNumberField\(TEXT\("rateScale"\), RateScale\)/g)).toHaveLength(2); + expect(source.match(/SetNumberField\(TEXT\("effectiveDurationSeconds"\), EffectiveDurationSeconds\)/g)).toHaveLength(2); + expect(source.match(/SetArrayField\(TEXT\("notifies"\), NotifyValues\)/g)).toHaveLength(2); + expect(source).toContain('SetField(TEXT("effectiveTriggerTimeSeconds"), MakeShared())'); + }); + + it("routes scalar controls through native Sequencer APIs with metadata and readback", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain("GetLocalControlRigBools("); + expect(source).toContain("SetLocalControlRigBools("); + expect(source).toContain("GetLocalControlRigFloats("); + expect(source).toContain("SetLocalControlRigFloats("); + expect(source).toContain("GetLocalControlRigInts("); + expect(source).toContain("SetLocalControlRigInts("); + expect(source).toContain("Session.Track->SetSectionToKey(Session.Section, Write.Control)"); + expect(source).toContain('SetStringField(TEXT("controlType"), ControlType)'); + expect(source).toContain('SetArrayField(TEXT("enumOptions"), Options)'); + expect(source).toContain("ControlRigSequencerIsValidEnumValue(ControlEnum, Write.IntValue)"); + expect(source).toContain("ControlRigSequencerRegisterWriteFrames(WrittenKeys"); + expect(source).toContain("SourceAnimation->GetAdditiveAnimType() != AAT_None"); + expect(source).toContain("ControlRigSequencerReadNormalizedQuaternion"); + expect(source).toContain("PreviousRotation | Rotation"); + expect(source).toContain("does not change a channel supported by"); + expect(source).toContain("ControlRigSequencerTransformMatches"); + }); + + it("implements component-space contact locking with transactional key QA", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_ControlRigSequencer.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain('Op == TEXT("contact_lock")'); + expect(source).toContain("ControlRigSequencerSmoothStep"); + expect(source).toContain("Skeleton->GetBoneTranslationRetargetingMode(SkeletonDriverIndex)"); + expect(source).toContain("AnimationCore::SolveFabrik("); + expect(source).toContain("DesiredLocal.SetTranslation(SourceLocal.GetTranslation())"); + expect(source).toContain("GetControlOffsetTransform("); + expect(source).toContain("FkChainControls.Num() - 1"); + expect(source).toContain('SetStringField(TEXT("solver"), TEXT("fk_rotation_chain"))'); + expect(source).toContain("UAnimPoseExtensions::GetAnimPoseAtTime("); + expect(source).toContain("SourceSection->MapTimeToAnimation("); + expect(source).toContain("GetRelativeTransformReverse("); + expect(source).toContain("CellCount > ControlRigSequencerMaxFrames"); + expect(source).toContain("contact_constraint_tolerance_exceeded"); + expect(source).toContain('TEXT("bake_and_analyze_required")'); + expect(source).toContain('SetArrayField(TEXT("contactQa"), ContactResults)'); + expect(source).toContain("MCPIsProtectedAssetPath(SequencePath)"); + expect(source.indexOf("for (FControlRigPreparedContactQA& Contact : PreparedContacts)")).toBeGreaterThan( + source.indexOf("BatchSetControlTransforms("), + ); + expect(animationTool.actions.contact_lock).toBeUndefined(); + }); + + it("makes partial IK retarget mappings explicit in batch results", () => { + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp", + import.meta.url, + ), + "utf8", + ); + + expect(source).toContain('SetArrayField(TEXT("unmappedTargetChains"), UnmappedTargetChains)'); + expect(source).toContain('SetBoolField(TEXT("mappingComplete"), UnmappedTargetChains.IsEmpty())'); + expect(source).toContain("SourceMesh->GetSkeleton()->IsCompatibleForEditor(Anim->GetSkeleton())"); + expect(source).toContain("FIKRetargetProcessor ValidationProcessor"); + expect(source).toContain("ValidationProcessor.IsInitialized()"); + expect(source).toContain("FScopedBatchRetargetEditorInstanceRestore RestoreEditorInstances"); + expect(source.indexOf("ValidationProcessor.Initialize(ValidationParameters)")).toBeLessThan( + source.indexOf("UIKRetargetBatchOperation::RunBatchRetarget(Inputs)"), + ); + expect(animationTool.actions.batch_retarget_animations.description).toContain("partial retargets are explicit"); + }); + + it("documents the UE 5.8 Control Rig boundary and cross-version analysis", () => { + for (const action of workflowActions) { + const spec = animationTool.actions[action]; + expect(spec).toBeDefined(); + expect(spec.bridge).toBe(action); + expect(spec.description).toContain("UE 5.8"); + expect(spec.description).toContain("unsupported_engine_version"); + expect(spec.description?.toLowerCase()).toContain("fallback"); + } + + // Do not advertise planned context inspection until its native handler exists. + expect(animationTool.actions.inspect_animation_context).toBeUndefined(); + + const analysis = animationTool.actions.analyze_animation; + expect(analysis.bridge).toBe("analyze_animation"); + expect(analysis.description).toContain("Cross-version"); + expect(analysis.description).not.toContain("unsupported_engine_version"); + + const source = readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_Validation.cpp", + import.meta.url, + ), + "utf8", + ); + expect(source).toContain("ENGINE_MINOR_VERSION >= 6"); + expect(source).toContain("static_cast(*SkeletalMesh)"); + }); + + it("makes the per-character Control Rig baseline a prerequisite", () => { + const begin = animationTool.actions.begin_control_rig_edit.description; + const skill = readFileSync( + new URL("../../skills/ue-mcp-animation/SKILL.md", import.meta.url), + "utf8", + ); + const guide = readFileSync( + new URL("../../docs/control-rig-animation.md", import.meta.url), + "utf8", + ); + + expect(begin).toContain("Baseline first"); + expect(begin).toContain("read_control_rig_hierarchy/read_control_rig_graph"); + expect(begin).toContain("epic_create"); + expect(begin).toContain("epic_import_bones_from_asset"); + expect(begin).toContain("epic_add_control"); + expect(begin).toContain("epic_add_backward_solve_graph"); + expect(begin).toContain("unchanged source round-trip"); + expect(begin).toContain("rejects rigs without inverse execution"); + expect(animationTool.schema.controlRigPath.description).toContain("verified baseline"); + expect(animationTool.schema.rigMode.description).toContain("verified baseline"); + expect(skill).toContain("Establish the character's authoring baseline"); + expect(skill).toContain("source-to-controls-to-bones round trip"); + expect(guide).toContain("Establish a per-character Control Rig baseline"); + expect(guide).toContain("it does not invent one"); + expect(guide).toContain("alone creates no imported bones"); + expect(guide.replace(/\s+/g, " ")).toContain("Do not create a new rig per animation"); + }); + + it("validates typed transform and scalar operations", () => { + const operations = animationTool.schema.operations; + + expect(operations.safeParse([ + { + op: "set", + control: "hand_r_ctrl", + frame: 12, + transform: { + translation: { x: 10, y: 2, z: 3 }, + rotationDegrees: { pitch: 5, yaw: 15, roll: -2 }, + scale: { x: 1, y: 1, z: 1 }, + }, + space: "global", + }, + { + op: "set_keys", + control: "hand_r_ik_ctrl", + keys: [ + { + frame: 6, + transform: { + translation: { x: 30, y: 4, z: 120 }, + rotationQuaternion: { x: 0, y: 0, z: 0, w: 1 }, + scale: { x: 1, y: 1, z: 1 }, + }, + }, + { + frame: 12, + transform: { + translation: { x: 34, y: 6, z: 126 }, + rotationQuaternion: { x: 0, y: 0, z: 0.173648, w: 0.984808 }, + scale: { x: 1, y: 1, z: 1 }, + }, + }, + ], + space: "global", + }, + { + op: "offset", + control: "foot_l_ctrl", + startFrame: 4, + endFrame: 18, + translationCm: { x: 0, y: 0, z: 2.5 }, + rotationDegrees: { pitch: 0, yaw: 3, roll: 0 }, + scaleMultiplier: { x: 1, y: 1, z: 1 }, + space: "local", + blendInFrames: 2, + blendOutFrames: 3, + }, + { + op: "contact_lock", + control: "foot_l_ik_ctrl", + drivenReference: "ball_l", + startFrame: 4, + endFrame: 18, + target: { + translation: { x: 12, y: -8, z: 1.5 }, + rotationQuaternion: { x: 0, y: 0, z: 0, w: 1 }, + }, + blendInFrames: 2, + blendOutFrames: 3, + stabilizeControls: ["knee_pole_l_ctrl"], + positionToleranceCm: 0.1, + rotationToleranceDegrees: 0.5, + }, + { + op: "set_bool", + control: "arm_r_fk_ik_switch", + frames: [0, 12, 30], + value: true, + }, + { + op: "set_float", + control: "hand_r_space_blend", + frame: 12, + value: 0.75, + }, + { + op: "set_int", + control: "hand_r_space", + frame: 12, + value: 2, + }, + ]).success).toBe(true); + + const completeTransform = { + translation: { x: 0, y: 0, z: 0 }, + rotationDegrees: { pitch: 0, yaw: 0, roll: 0 }, + scale: { x: 1, y: 1, z: 1 }, + }; + expect(operations.safeParse([{ op: "set", control: "root_ctrl", transform: completeTransform }]).success).toBe(false); + expect(operations.safeParse([{ op: "set", control: "root_ctrl", frame: 0, frames: [0], transform: completeTransform }]).success).toBe(false); + expect(operations.safeParse([{ op: "offset", control: "root_ctrl", startFrame: 10, endFrame: 2, translationCm: { x: 1, y: 0, z: 0 } }]).success).toBe(false); + expect(operations.safeParse([{ op: "offset", control: "root_ctrl", startFrame: 0, endFrame: 2 }]).success).toBe(false); + const contactTarget = { translation: { x: 0, y: 0, z: 0 } }; + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 10, endFrame: 2, target: contactTarget }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 4, target: contactTarget, blendInFrames: 3, blendOutFrames: 2 }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 4, target: contactTarget, stabilizeControls: ["pole", "POLE"] }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 4, target: contactTarget, stabilizeControls: ["FOOT_IK"] }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 50_000, target: contactTarget, stabilizeControls: ["pole"] }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 4, target: { ...contactTarget, rotationQuaternion: { x: 0, y: 0, z: 0, w: 2 } } }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 4, target: { ...contactTarget, scale: { x: 1, y: 1, z: 1 } } }]).success).toBe(false); + expect(operations.safeParse([{ op: "contact_lock", control: "foot_ik", startFrame: 0, endFrame: 4, target: contactTarget, positionToleranceCm: 0 }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_bool", control: "arm_r_fk_ik_switch", frame: 0, value: 1 }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_bool", control: "arm_r_fk_ik_switch", frame: 0, frames: [0], value: true }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_float", control: "blend", frame: 0, value: Number.POSITIVE_INFINITY }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_int", control: "space", frame: 0, value: 1.5 }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_int", control: "space", frame: 0, frames: [0], value: 1 }]).success).toBe(false); + expect(animationTool.schema.createLink.safeParse(true).success).toBe(false); + const quaternionTransform = { + translation: { x: 0, y: 0, z: 0 }, + rotationQuaternion: { x: 0, y: 0, z: 0, w: 1 }, + scale: { x: 1, y: 1, z: 1 }, + }; + expect(operations.safeParse([{ op: "set_keys", control: "hand_r_ik_ctrl", keys: [ + { frame: 2, transform: quaternionTransform }, + { frame: 2, transform: quaternionTransform }, + ] }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_keys", control: "hand_r_ik_ctrl", keys: [{ + frame: 2, + transform: { ...quaternionTransform, rotationQuaternion: { x: 0, y: 0, z: 0, w: 2 } }, + }] }]).success).toBe(false); + expect(operations.safeParse([{ op: "set_keys", control: "hand_r_ik_ctrl", keys: [{ + frame: 2, + transform: { translation: { x: 0, y: 0, z: 0 }, rotationQuaternion: { x: 0, y: 0, z: 0, w: 1 } }, + }] }]).success).toBe(false); + expect(animationTool.schema.rigMode.safeParse("fk").success).toBe(true); + expect(animationTool.schema.rigMode.safeParse("asset").success).toBe(true); + expect(animationTool.schema.rigMode.safeParse("python").success).toBe(false); + }); + + it("maps begin/read/apply/bake parameters exactly to the native contracts", async () => { + const call = vi.fn().mockResolvedValue({ success: true }); + const ctx = { bridge: { call } } as unknown as ToolContext; + + await animationTool.handler(ctx, { + action: "begin_control_rig_edit", + sequencePath: "/Game/MCP/LS_Wave_Edit", + skeletalMeshPath: "/Game/Characters/Mannequins/Meshes/SKM_Manny", + sourceAnimationPath: "/Game/Characters/Mannequins/Animations/ABP_Manny/MM_Unarmed_Idle_Ready", + rigMode: "asset", + controlRigPath: "/Game/Characters/Mannequins/Rigs/CR_Mannequin_Body", + layered: true, + startFrame: 0, + endFrame: 60, + displayRate: 30, + bindingTag: "mcp.manny.wave", + onConflict: "skip", + assetPath: "/Game/ShouldNotLeak", + }); + expect(call).toHaveBeenLastCalledWith("begin_control_rig_edit", { + sequencePath: "/Game/MCP/LS_Wave_Edit", + skeletalMeshPath: "/Game/Characters/Mannequins/Meshes/SKM_Manny", + sourceAnimationPath: "/Game/Characters/Mannequins/Animations/ABP_Manny/MM_Unarmed_Idle_Ready", + rigMode: "asset", + controlRigPath: "/Game/Characters/Mannequins/Rigs/CR_Mannequin_Body", + layered: true, + startFrame: 0, + endFrame: 60, + displayRate: 30, + bindingTag: "mcp.manny.wave", + onConflict: "skip", + }, undefined); + + await animationTool.handler(ctx, { + action: "read_control_rig_edit", + sequencePath: "/Game/MCP/LS_Wave_Edit", + bindingTag: "mcp.manny.wave", + controlNames: ["hand_r_ctrl", "foot_l_ctrl"], + frames: [0, 12, 30], + space: "global", + sourceAnimationPath: "/Game/ShouldNotLeak", + }); + expect(call).toHaveBeenLastCalledWith("read_control_rig_edit", { + sequencePath: "/Game/MCP/LS_Wave_Edit", + bindingTag: "mcp.manny.wave", + controlNames: ["hand_r_ctrl", "foot_l_ctrl"], + frames: [0, 12, 30], + space: "global", + }, undefined); + + const operations = [{ + op: "set", + control: "hand_r_ctrl", + frames: [12, 18], + transform: { + translation: { x: 35, y: 8, z: 125 }, + rotationDegrees: { pitch: 0, yaw: 30, roll: 10 }, + scale: { x: 1, y: 1, z: 1 }, + }, + space: "global", + }]; + await animationTool.handler(ctx, { + action: "apply_control_rig_edits", + sequencePath: "/Game/MCP/LS_Wave_Edit", + bindingTag: "mcp.manny.wave", + operations, + frames: [999], + }); + expect(call).toHaveBeenLastCalledWith("apply_control_rig_edits", { + sequencePath: "/Game/MCP/LS_Wave_Edit", + bindingTag: "mcp.manny.wave", + operations, + }, undefined); + + await animationTool.handler(ctx, { + action: "bake_control_rig_edit", + sequencePath: "/Game/MCP/LS_Wave_Edit", + bindingTag: "mcp.manny.wave", + outputAssetPath: "/Game/MCP/Animations/AN_Manny_Wave", + frameRate: 30, + reduceKeys: false, + tolerance: 0.001, + createLink: false, + onConflict: "error", + operations, + }); + expect(call).toHaveBeenLastCalledWith("bake_control_rig_edit", { + sequencePath: "/Game/MCP/LS_Wave_Edit", + bindingTag: "mcp.manny.wave", + outputAssetPath: "/Game/MCP/Animations/AN_Manny_Wave", + frameRate: 30, + reduceKeys: false, + tolerance: 0.001, + createLink: false, + onConflict: "error", + }, undefined); + }); + + it("maps the AnimSequence-only analysis contract without leaking edit-session fields", async () => { + const call = vi.fn().mockResolvedValue({ success: true }); + const ctx = { bridge: { call } } as unknown as ToolContext; + + await animationTool.handler(ctx, { + action: "analyze_animation", + assetPath: "/Game/Characters/Mannequins/Animations/ABP_Manny/MM_Unarmed_Idle_Ready", + skeletalMeshPath: "/Game/Characters/Mannequins/Meshes/SKM_Manny", + boneNames: ["root", "pelvis", "foot_l", "foot_r"], + frames: [0, 15, 30, 45, 60], + sampleRate: 30, + loop: true, + outputDirectory: "manny_idle_ready", + sequencePath: "/Game/ShouldNotLeak", + bindingTag: "should-not-leak", + }); + + expect(call).toHaveBeenCalledWith("analyze_animation", { + assetPath: "/Game/Characters/Mannequins/Animations/ABP_Manny/MM_Unarmed_Idle_Ready", + skeletalMeshPath: "/Game/Characters/Mannequins/Meshes/SKM_Manny", + boneNames: ["root", "pelvis", "foot_l", "foot_r"], + frames: [0, 15, 30, 45, 60], + sampleRate: 30, + loop: true, + outputDirectory: "manny_idle_ready", + }, undefined); + }); +}); diff --git a/tests/unit/animation-ik-authoring.test.ts b/tests/unit/animation-ik-authoring.test.ts new file mode 100644 index 00000000..deba3f10 --- /dev/null +++ b/tests/unit/animation-ik-authoring.test.ts @@ -0,0 +1,204 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { animationTool } from "../../src/tools/animation.js"; +import type { ToolContext } from "../../src/types.js"; + +describe("animation IK and retarget authoring", () => { + it("publishes the native UE 5.8 authoring boundary", () => { + for (const action of ["configure_ik_rig", "configure_ik_retargeter"] as const) { + const spec = animationTool.actions[action]; + expect(spec.bridge).toBe(action); + expect(spec.description).toContain("UE 5.8"); + expect(spec.description).toContain("unsupported_engine_version"); + } + expect(animationTool.actions.read_ik_rig.description).toContain("concrete goals"); + expect(animationTool.actions.read_ik_retargeter.description).toContain("per-op chain mappings"); + }); + + it("validates typed IK and retarget payloads", () => { + expect(animationTool.schema.autoSetup.safeParse("full_body").success).toBe(true); + expect(animationTool.schema.autoSetup.safeParse("reflection").success).toBe(false); + expect(animationTool.schema.fullBodyIK.safeParse({ + rootBone: "pelvis", + goals: [{ + name: "hand_r_Goal", + bone: "hand_r", + positionAlpha: 1, + rotationAlpha: 1, + chainDepth: 0, + strengthAlpha: 1, + pullChainAlpha: 0, + pinRotation: 1, + }], + }).success).toBe(true); + expect(animationTool.schema.fullBodyIK.safeParse({ + rootBone: "pelvis", + goals: [{ name: "bad", bone: "hand_r", strengthAlpha: 2 }], + }).success).toBe(false); + expect(animationTool.schema.fullBodyIK.safeParse({ + rootBone: "pelvis", + goals: Array.from({ length: 257 }, (_, index) => ({ name: `goal_${index}`, bone: "hand_r" })), + }).success).toBe(false); + expect(animationTool.schema.chains.safeParse(Array.from({ length: 257 }, (_, index) => ({ + name: `chain_${index}`, + startBone: "pelvis", + endBone: "head", + }))).success).toBe(false); + expect(animationTool.schema.exclusions.safeParse(Array.from({ length: 2049 }, (_, index) => ({ + bone: `bone_${index}`, + excluded: true, + }))).success).toBe(false); + + expect(animationTool.schema.chainMappings.safeParse([ + { targetChain: "LeftArm", sourceChain: "LeftArm" }, + { targetChain: "LeftMetacarpal", sourceChain: null }, + ]).success).toBe(true); + expect(animationTool.schema.pose.safeParse({ + side: "target", + name: "Manny Retarget Pose", + create: true, + autoAlign: "chain_to_chain", + rotationOffsets: [{ + bone: "upperarm_r", + rotationQuaternion: { x: 0, y: 0, z: 0, w: 1 }, + }], + rootOffsetZ: 2.5, + }).success).toBe(true); + expect(animationTool.schema.pose.safeParse({ + side: "target", + name: "bad", + rootOffsetZ: 1, + snapBoneToGround: "ball_l", + }).success).toBe(false); + expect(animationTool.schema.pose.safeParse({ + side: "target", + name: "bad", + rotationOffsets: [{ + bone: "upperarm_r", + rotationQuaternion: { x: 0, y: 0, z: 0, w: 2 }, + }], + }).success).toBe(false); + }); + + it("maps only the native configure contracts", async () => { + const call = vi.fn().mockResolvedValue({ success: true }); + const context = { bridge: { call } } as unknown as ToolContext; + + const chains = [{ name: "RightArm", startBone: "upperarm_r", endBone: "hand_r", goal: "hand_r_Goal" }]; + const fullBodyIK = { + rootBone: "pelvis", + goals: [{ name: "hand_r_Goal", bone: "hand_r", strengthAlpha: 1 }], + }; + await animationTool.handler(context, { + action: "configure_ik_rig", + rigPath: "/Game/Rigs/IK_Manny", + autoSetup: "full_body", + retargetRoot: "pelvis", + rootMotionBone: "root", + chains, + fullBodyIK, + exclusions: [{ bone: "neck_01", excluded: false }], + retargeterPath: "/Game/ShouldNotLeak", + }); + expect(call).toHaveBeenLastCalledWith("configure_ik_rig", { + rigPath: "/Game/Rigs/IK_Manny", + autoSetup: "full_body", + retargetRoot: "pelvis", + rootMotionBone: "root", + chains, + fullBodyIK, + exclusions: [{ bone: "neck_01", excluded: false }], + }, undefined); + + const pose = { side: "target", name: "Manny Pose", create: true, autoAlign: "chain_to_chain" }; + await animationTool.handler(context, { + action: "configure_ik_retargeter", + retargeterPath: "/Game/Rigs/RTG_UE4_Manny", + sourceRig: "/Game/Rigs/IK_UE4", + targetRig: "/Game/Rigs/IK_Manny", + sourcePreviewMesh: "/Game/Meshes/SK_UE4", + targetPreviewMesh: "/Game/Meshes/SKM_Manny", + ensureDefaultOps: true, + autoMapMode: "exact", + forceRemap: true, + chainMappings: [{ targetChain: "LeftArm", sourceChain: "LeftArm" }], + pose, + rigPath: "/Game/ShouldNotLeak", + }); + expect(call).toHaveBeenLastCalledWith("configure_ik_retargeter", { + retargeterPath: "/Game/Rigs/RTG_UE4_Manny", + sourceRig: "/Game/Rigs/IK_UE4", + targetRig: "/Game/Rigs/IK_Manny", + sourcePreviewMesh: "/Game/Meshes/SK_UE4", + targetPreviewMesh: "/Game/Meshes/SKM_Manny", + ensureDefaultOps: true, + autoMapMode: "exact", + forceRemap: true, + chainMappings: [{ targetChain: "LeftArm", sourceChain: "LeftArm" }], + pose, + }, undefined); + }); + + it("registers guarded native handlers with transactions and checked saves", () => { + const registry = readFileSync(new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers.cpp", + import.meta.url, + ), "utf8"); + const ik = readFileSync(new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRigAuthoring.cpp", + import.meta.url, + ), "utf8"); + const retarget = readFileSync(new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_IKRetargeterAuthoring.cpp", + import.meta.url, + ), "utf8"); + const legacy = readFileSync(new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/AnimationHandlers_StateMachine.cpp", + import.meta.url, + ), "utf8"); + const handlerUtils = readFileSync(new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Public/HandlerUtils.h", + import.meta.url, + ), "utf8"); + + expect(registry).toContain('TEXT("configure_ik_rig"), &ConfigureIKRig'); + expect(registry).toContain('TEXT("configure_ik_retargeter"), &ConfigureIKRetargeter'); + for (const source of [ik, retarget]) { + expect(source).toContain("UE_MCP_HAS_5_8_API"); + expect(source).toContain('TEXT("unsupported_engine_version")'); + expect(source).toContain("FScopedTransaction"); + expect(source).toContain("UndoTransaction"); + } + expect(ik).toContain("SaveLoadedAsset"); + expect(retarget).toContain("SaveAssetPackage"); + expect(ik).toContain("MCPIsProtectedAssetPath(RigPath)"); + expect(ik).toContain("TSet RequiredFBIKBones"); + expect(ik).toContain("ExistingSolver->GetRequiredGoals(ConnectedGoals)"); + expect(ik).toContain("RequiredFBIKBones.Add(AutoResults.AutoRetargetDefinition.RetargetDefinition.PelvisBone)"); + expect(ik).toContain("Exclusion.bExcluded && RequiredFBIKBones.Contains(Exclusion.Bone)"); + expect(retarget).toContain("MCPIsProtectedAssetPath(RetargeterPath)"); + expect(retarget).toContain("pose.snapBoneToGround requires both source and target preview meshes"); + expect(retarget).toContain("PoseMesh->GetRefSkeleton().FindBoneIndex(SnapBone)"); + expect(retarget).toContain("MappingProcessor.IsBoneMapped(Bone, Pose.Side)"); + expect(retarget).toContain("Retarget pose auto-align bone is not mapped"); + expect(legacy).toContain('SetStringField(TEXT("rootMotionBone")'); + expect(legacy).toContain('SetArrayField(TEXT("goals")'); + expect(legacy).toContain('SetArrayField(TEXT("retargetOps")'); + expect(legacy).toContain("Inputs.bIncludeReferencedAssets = false"); + expect(legacy).toContain("requireCompleteMapping"); + expect(legacy).toContain("UEditorAssetLibrary::DoesAssetExist(ObjectPath)"); + expect(legacy).toContain("cleanup failed for"); + const setRig = legacy.slice( + legacy.indexOf("FAnimationHandlers::SetIKRetargeterRig"), + legacy.indexOf("FAnimationHandlers::AutoAlignRetargetPose"), + ); + expect(setRig).toContain("if (Controller->GetNumRetargetOps() == 0)"); + expect(setRig).toContain("MCPIsProtectedAssetPath(RetargeterPath)"); + expect(setRig).toContain("FScopedTransaction"); + expect(setRig).toContain("UndoTransaction"); + expect(legacy).toContain("MCPIsProtectedAssetPath(TargetPath)"); + expect(handlerUtils).toContain('Lower == TEXT("/engine")'); + expect(handlerUtils).toContain('Lower == TEXT("/script")'); + expect(handlerUtils).toContain("FPackageName::ExportTextPathToObjectPath(Normalized)"); + }); +}); diff --git a/tests/unit/editor-lifecycle-target.test.ts b/tests/unit/editor-lifecycle-target.test.ts index a54519c6..7aab736b 100644 --- a/tests/unit/editor-lifecycle-target.test.ts +++ b/tests/unit/editor-lifecycle-target.test.ts @@ -155,3 +155,32 @@ describe("start and restart without a loaded project", () => { expect(findInteractiveEditors).not.toHaveBeenCalled(); }); }); + +describe("native editor shutdown", () => { + it("uses MainFrame so standalone asset editors close before subsystem teardown", () => { + const source = fs.readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/Private/Handlers/EditorHandlers.cpp", + import.meta.url, + ), + "utf8", + ); + const buildRules = fs.readFileSync( + new URL( + "../../plugin/ue_mcp_bridge/Source/UE_MCP_Bridge/UE_MCP_Bridge.Build.cs", + import.meta.url, + ), + "utf8", + ); + const shutdownHandler = source.slice( + source.indexOf("FEditorHandlers::RequestEditorShutdown"), + source.indexOf("FEditorHandlers::FocusViewportOnActor"), + ); + + expect(source).toContain('#include "Interfaces/IMainFrameModule.h"'); + expect(buildRules).toContain('"MainFrame",'); + expect(shutdownHandler).toContain("FModuleManager::LoadModuleChecked"); + expect(shutdownHandler).toContain("MainFrameModule.RequestCloseEditor()"); + expect(shutdownHandler).not.toContain("UKismetSystemLibrary::QuitEditor()"); + }); +}); diff --git a/tests/unit/write-methods.test.ts b/tests/unit/write-methods.test.ts index f8bed8c7..e1db7e47 100644 --- a/tests/unit/write-methods.test.ts +++ b/tests/unit/write-methods.test.ts @@ -82,4 +82,50 @@ describe("classifyWrite", () => { const r = classifyWrite("save_asset", { assetPath: 123 }); expect(r.writes).toBe(false); }); + + it("extracts the actual Control Rig edit outputs", () => { + expect(classifyWrite("begin_control_rig_edit", { sequencePath: "/Game/Edit/LS_A" }).contentPaths) + .toEqual(["/Game/Edit/LS_A"]); + expect(classifyWrite("apply_control_rig_edits", { sequencePath: "/Game/Edit/LS_A" }).contentPaths) + .toEqual(["/Game/Edit/LS_A"]); + expect(classifyWrite("bake_control_rig_edit", { + sequencePath: "/Game/Edit/LS_A", + outputAssetPath: "/Game/Edit/A_Result", + }).contentPaths).toEqual(["/Game/Edit/A_Result"]); + }); + + it("extracts the edited IK or retargeter asset, not referenced inputs", () => { + const cases: Array<[string, Record, string]> = [ + ["configure_ik_rig", { + rigPath: "/Game/Rigs/IK_A", + skeletalMeshPath: "/Game/Characters/SK_A", + }, "/Game/Rigs/IK_A"], + ["configure_ik_retargeter", { + retargeterPath: "/Game/Rigs/RTG_A", + sourceRig: "/Game/Rigs/IK_Source", + targetRig: "/Game/Rigs/IK_Target", + }, "/Game/Rigs/RTG_A"], + ["set_ik_rig_mesh", { + rigPath: "/Game/Rigs/IK_A", + meshPath: "/Game/Characters/SK_A", + }, "/Game/Rigs/IK_A"], + ["set_ik_retargeter_rig", { + retargeterPath: "/Game/Rigs/RTG_A", + rigPath: "/Game/Rigs/IK_Target", + }, "/Game/Rigs/RTG_A"], + ["auto_align_retarget_pose", { + retargeterPath: "/Game/Rigs/RTG_A", + }, "/Game/Rigs/RTG_A"], + ["reset_retarget_pose", { + retargeterPath: "/Game/Rigs/RTG_A", + }, "/Game/Rigs/RTG_A"], + ]; + + for (const [method, params, expectedPath] of cases) { + expect(classifyWrite(method, params), method).toEqual({ + writes: true, + contentPaths: [expectedPath], + }); + } + }); });