FModel/FModel/ViewModels/AnimGraphViewModel.cs
copilot-swe-agent[bot] 933bfa0ec0 Fix incorrect layer for chained SaveCachedPose/UseCachedPose dependencies
When sequential Save/Use dependencies exist (e.g., UseCachedPose(A)->
SaveCachedPose(B)->UseCachedPose(B)->SaveCachedPose(C)), the stale
BuildLayerLookups built once before the loop caused FindOwnerRootLayer
to miss consumers assigned during earlier iterations.

The fix iteratively processes SaveCachedPose nodes: each pass rebuilds
lookups and resolves nodes whose consumers are already placed, deferring
nodes with unresolved consumers. A maxPasses guard prevents infinite loops.

Co-authored-by: LoogLong <86428208+LoogLong@users.noreply.github.com>
2026-03-05 08:23:09 +00:00

1259 lines
52 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using CUE4Parse.UE4.Assets.Exports;
using CUE4Parse.UE4.Assets.Objects;
using CUE4Parse.UE4.Assets.Objects.Properties;
using CUE4Parse.UE4.Objects.UObject;
namespace FModel.ViewModels;
public class AnimGraphNode
{
public string Name { get; set; } = string.Empty;
public string ExportType { get; set; } = string.Empty;
public string NodeComment { get; set; } = string.Empty;
public int NodePosX { get; set; }
public int NodePosY { get; set; }
public bool IsStateMachineState { get; set; }
public bool IsEntryNode { get; set; }
public List<AnimGraphPin> Pins { get; set; } = [];
public Dictionary<string, string> AdditionalProperties { get; set; } = new();
public override string ToString() => $"{ExportType} ({Name})";
}
public class AnimGraphPin
{
public string PinName { get; set; } = string.Empty;
public bool IsOutput { get; set; }
public string PinType { get; set; } = string.Empty;
public string DefaultValue { get; set; } = string.Empty;
public AnimGraphNode OwnerNode { get; set; } = null!;
}
public class AnimGraphConnection
{
public AnimGraphNode SourceNode { get; set; } = null!;
public string SourcePinName { get; set; } = string.Empty;
public AnimGraphNode TargetNode { get; set; } = null!;
public string TargetPinName { get; set; } = string.Empty;
/// <summary>
/// Additional properties for state machine transitions (e.g. CrossfadeDuration, LogicType).
/// </summary>
public Dictionary<string, string> TransitionProperties { get; set; } = new();
}
/// <summary>
/// Represents a layer/sub-graph within the animation blueprint,
/// similar to how UE's Animation Blueprint editor organizes nodes
/// into separate tabs (AnimGraph, StateMachine sub-graphs, etc.).
/// </summary>
public class AnimGraphLayer
{
public string Name { get; set; } = string.Empty;
public List<AnimGraphNode> Nodes { get; } = [];
public List<AnimGraphConnection> Connections { get; } = [];
}
/// <summary>
/// Holds metadata extracted from BakedStateMachines for building
/// state machine overview layers (Entry + State nodes + Transition connections).
/// </summary>
internal class StateMachineMetadata
{
public string MachineName { get; init; } = string.Empty;
public List<string> StateNames { get; } = [];
public List<string> StateRootPropNames { get; } = [];
public List<(int PreviousState, int NextState, Dictionary<string, string> Properties)> Transitions { get; } = [];
}
public class AnimGraphViewModel
{
private const int GridColumns = 4;
private const int NodeHorizontalSpacing = 300;
private const int NodeVerticalSpacing = 200;
private const int StateNodeHorizontalSpacing = 250;
private const int StateNodeVerticalSpacing = 150;
private const int MaxPropertyValueDisplayLength = 100;
internal const string SubGraphPathSeparator = " > ";
public string PackageName { get; set; } = string.Empty;
public List<AnimGraphNode> Nodes { get; } = [];
public List<AnimGraphConnection> Connections { get; } = [];
/// <summary>
/// Animation blueprint graph layers, each defined by a unique AnimGraphNode_Root.
/// </summary>
public List<AnimGraphLayer> Layers { get; } = [];
/// <summary>
/// State machine state sub-graphs, keyed by the _StateResult root node's property name
/// (derived from StateRootNodeIndex) for unique identification.
/// </summary>
public Dictionary<string, AnimGraphLayer> StateSubGraphs { get; } = new();
/// <summary>
/// Extracts animation graph node information from a UAnimBlueprintGeneratedClass.
/// In cooked assets, graph nodes (UEdGraphNode) are stripped as editor-only data.
/// The actual animation node data is stored in:
/// - ChildProperties (FField[]) on the class: describes the struct property types (e.g., FAnimNode_StateMachine)
/// - ClassDefaultObject properties: contains the actual struct values (FStructFallback) with node data
/// </summary>
public static AnimGraphViewModel ExtractFromClass(UClass animBlueprintClass)
{
var vm = new AnimGraphViewModel { PackageName = animBlueprintClass.Owner?.Name ?? animBlueprintClass.Name };
// Load the ClassDefaultObject which contains the actual property values
var cdo = animBlueprintClass.ClassDefaultObject.Load();
// Extract animation node properties from ChildProperties metadata
// and their corresponding values from the CDO
var childProps = animBlueprintClass.ChildProperties;
if (childProps == null || childProps.Length == 0)
return vm;
// Collect all anim node struct properties from the class definition
var animNodeProps = new List<(string name, string structType)>();
foreach (var field in childProps)
{
if (field is not FStructProperty structProp) continue;
var structName = structProp.Struct.ResolvedObject?.Name.Text ?? string.Empty;
// Animation node structs typically start with "FAnimNode_" or "AnimNode_"
if (!IsAnimNodeStruct(structName) && !IsAnimNodeStruct(field.Name.Text))
continue;
animNodeProps.Add((field.Name.Text, structName));
}
// Build nodes from the collected properties
var nodeByName = new Dictionary<string, AnimGraphNode>();
foreach (var (propName, structType) in animNodeProps)
{
var node = new AnimGraphNode
{
Name = propName,
ExportType = structType
};
// Try to extract property values from the CDO
if (cdo != null)
{
ExtractNodeProperties(cdo, propName, node);
}
// Add a default output pin for each node
node.Pins.Add(new AnimGraphPin
{
PinName = "Output",
IsOutput = true,
PinType = "pose",
OwnerNode = node
});
nodeByName[propName] = node;
vm.Nodes.Add(node);
}
// Resolve connections between nodes using CDO property values
if (cdo != null)
{
ResolveConnections(cdo, animNodeProps, nodeByName, vm);
}
// Associate state machine nodes with their baked machine names
// and collect state machine metadata for overview layers
var smMetadata = new List<StateMachineMetadata>();
AssociateStateMachineNames(animBlueprintClass, cdo, animNodeProps, nodeByName, smMetadata);
// Group nodes into layers (connected subgraphs)
BuildLayers(vm);
// Prefix state machine internal layers with their parent path to avoid name collisions
PrefixStateMachineLayerNames(vm);
// Build state machine overview layers (Entry + State nodes + Transitions)
BuildStateMachineOverviewLayers(vm, smMetadata);
return vm;
}
/// <summary>
/// Groups nodes into layers based on root nodes. In Unreal Engine:
/// - Each animation blueprint layer has a unique AnimGraphNode_Root
/// - Each state machine state sub-graph has a unique AnimGraphNode_StateResult
/// These two types are processed in separate passes to keep graph layers
/// and state machine state sub-graphs independent.
/// </summary>
private static void BuildLayers(AnimGraphViewModel vm)
{
if (vm.Nodes.Count == 0) return;
// Build upstream map: for each node, which nodes feed into it
// Connection direction: SourceNode (provider) → TargetNode (consumer)
var upstreamOf = new Dictionary<AnimGraphNode, List<AnimGraphNode>>();
foreach (var node in vm.Nodes)
upstreamOf[node] = [];
foreach (var conn in vm.Connections)
upstreamOf[conn.TargetNode].Add(conn.SourceNode);
var assigned = new HashSet<AnimGraphNode>();
var layerIndex = 0;
// Pass 1: Build graph layers from AnimGraphNode_Root nodes.
// Each _Root node defines an animation blueprint layer (e.g. "AnimGraph").
var graphRoots = vm.Nodes
.Where(n => n.ExportType.EndsWith("_Root", StringComparison.OrdinalIgnoreCase))
.ToList();
AnimGraphLayer? primaryGraphLayer = null;
foreach (var rootNode in graphRoots)
{
if (!assigned.Add(rootNode)) continue;
var layerNodes = CollectUpstream(rootNode, upstreamOf, assigned);
AddLayer(vm, layerNodes, layerIndex++);
primaryGraphLayer ??= vm.Layers[^1];
}
// Determine the outermost animation blueprint layer for fallback.
// In UE, the outermost layer is always named "AnimGraph".
// If found, use it; otherwise fall back to the first _Root layer.
var outermostGraphLayer = vm.Layers.FirstOrDefault(l =>
l.Name.Equals("AnimGraph", StringComparison.OrdinalIgnoreCase)) ?? primaryGraphLayer;
// Pass 2: Build state machine state sub-graphs from AnimGraphNode_StateResult nodes.
// Each _StateResult node defines a state's sub-graph within a state machine.
// These are stored in StateSubGraphs keyed by the root node's property name.
// SaveCachedPose nodes are excluded because they belong to the parent AnimGraph layer.
var stateResultRoots = vm.Nodes
.Where(n => n.ExportType.EndsWith("_StateResult", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var stateResultNode in stateResultRoots)
{
if (!assigned.Add(stateResultNode)) continue;
var layerNodes = CollectUpstream(stateResultNode, upstreamOf, assigned,
excludeNode: IsSaveCachedPoseNode);
AddStateSubGraph(vm, layerNodes, stateResultNode.Name, layerIndex++);
}
// Pass 3: Assign unassigned SaveCachedPose nodes to their correct _Root layer.
// SaveCachedPose can only exist in animation blueprint layers (defined by _Root nodes).
// Determine the correct _Root layer by tracing downstream UseCachedPose consumers
// back through the state machine hierarchy to their parent animation blueprint layer.
// Each SaveCachedPose's upstream chain excludes other SaveCachedPose nodes so that
// chained SaveCachedPose → UseCachedPose → SaveCachedPose are independently placed.
//
// When chains exist (e.g. UseCachedPose(A) → … → SaveCachedPose(B) →
// UseCachedPose(B) → … → SaveCachedPose(C)), the UseCachedPose consumers of
// upstream SaveCachedPose nodes may only appear in a layer after the downstream
// SaveCachedPose is processed. We therefore iterate: each pass rebuilds lookups
// and resolves nodes whose consumers are already placed, deferring the rest.
if (outermostGraphLayer != null)
{
var pending = vm.Nodes
.Where(n => !assigned.Contains(n) && IsSaveCachedPoseNode(n))
.ToList();
if (pending.Count > 0)
{
var affectedLayers = new HashSet<AnimGraphLayer>();
bool madeProgress;
var maxPasses = pending.Count;
do
{
madeProgress = false;
var lookups = BuildLayerLookups(vm);
for (var i = pending.Count - 1; i >= 0; i--)
{
var saveNode = pending[i];
var targetLayer = FindOwnerRootLayer(saveNode, vm, lookups);
// If no layer was found, check whether the failure is because
// some consumer nodes are not yet in any layer (deferred
// dependency). If so, skip this node and retry next pass.
if (targetLayer == null)
{
var hasDeferredConsumer = false;
foreach (var conn in vm.Connections)
{
if (conn.SourceNode == saveNode &&
!lookups.NodeToLayer.ContainsKey(conn.TargetNode))
{
hasDeferredConsumer = true;
break;
}
}
if (hasDeferredConsumer)
continue;
targetLayer = outermostGraphLayer;
}
assigned.Add(saveNode);
var inputChain = CollectUpstream(saveNode, upstreamOf, assigned,
excludeNode: IsSaveCachedPoseNode);
targetLayer.Nodes.AddRange(inputChain);
affectedLayers.Add(targetLayer);
pending.RemoveAt(i);
madeProgress = true;
}
} while (madeProgress && pending.Count > 0 && --maxPasses > 0);
// Fallback: remaining nodes (circular deps or truly unresolvable)
foreach (var saveNode in pending)
{
if (!assigned.Add(saveNode)) continue;
var inputChain = CollectUpstream(saveNode, upstreamOf, assigned,
excludeNode: IsSaveCachedPoseNode);
outermostGraphLayer.Nodes.AddRange(inputChain);
affectedLayers.Add(outermostGraphLayer);
}
foreach (var layer in affectedLayers)
RebuildLayerConnections(vm, layer);
}
}
// Fallback: any remaining unassigned nodes go into connected-component layers
var remaining = vm.Nodes.Where(n => !assigned.Contains(n)).ToList();
if (remaining.Count > 0)
{
var adjacency = new Dictionary<AnimGraphNode, HashSet<AnimGraphNode>>();
foreach (var node in remaining)
adjacency[node] = [];
foreach (var conn in vm.Connections)
{
if (adjacency.ContainsKey(conn.SourceNode) && adjacency.ContainsKey(conn.TargetNode))
{
adjacency[conn.SourceNode].Add(conn.TargetNode);
adjacency[conn.TargetNode].Add(conn.SourceNode);
}
}
foreach (var node in remaining)
{
if (!assigned.Add(node)) continue;
var component = new List<AnimGraphNode> { node };
var queue = new Queue<AnimGraphNode>();
queue.Enqueue(node);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var neighbor in adjacency[current])
{
if (assigned.Add(neighbor))
{
component.Add(neighbor);
queue.Enqueue(neighbor);
}
}
}
AddLayer(vm, component, layerIndex++);
}
}
// Final enforcement: SaveCachedPose nodes can only exist in animation
// blueprint layers (layers that contain a _Root node). After all passes
// above, scan every non-_Root layer (state sub-graphs and fallback layers)
// and move any SaveCachedPose nodes to the correct _Root layer.
if (outermostGraphLayer != null)
{
EnforceSaveCachedPoseInRootLayers(vm, outermostGraphLayer);
}
}
/// <summary>
/// Ensures every SaveCachedPose node resides in an animation blueprint layer
/// (one that contains a _Root node). Any SaveCachedPose found in a state
/// sub-graph or a fallback connected-component layer is moved to the correct
/// _Root layer determined by tracing its UseCachedPose consumers.
/// Falls back to <paramref name="fallbackLayer"/> if no better target is found.
/// </summary>
private static void EnforceSaveCachedPoseInRootLayers(
AnimGraphViewModel vm, AnimGraphLayer fallbackLayer)
{
// Identify animation-blueprint layers (layers that contain a _Root node)
var animBlueprintLayers = new HashSet<AnimGraphLayer>();
foreach (var layer in vm.Layers)
{
if (layer.Nodes.Any(n => n.ExportType.EndsWith("_Root", StringComparison.OrdinalIgnoreCase)))
animBlueprintLayers.Add(layer);
}
// Collect SaveCachedPose nodes from non-_Root layers and state sub-graphs
var moves = new List<(AnimGraphNode node, AnimGraphLayer sourceLayer)>();
foreach (var layer in vm.Layers)
{
if (animBlueprintLayers.Contains(layer)) continue;
foreach (var node in layer.Nodes.Where(IsSaveCachedPoseNode).ToList())
moves.Add((node, layer));
}
foreach (var (_, layer) in vm.StateSubGraphs)
{
foreach (var node in layer.Nodes.Where(IsSaveCachedPoseNode).ToList())
moves.Add((node, layer));
}
if (moves.Count == 0) return;
var lookups = BuildLayerLookups(vm);
var affectedLayers = new HashSet<AnimGraphLayer>();
foreach (var (node, sourceLayer) in moves)
{
var targetLayer = FindOwnerRootLayer(node, vm, lookups) ?? fallbackLayer;
sourceLayer.Nodes.Remove(node);
targetLayer.Nodes.Add(node);
affectedLayers.Add(sourceLayer);
affectedLayers.Add(targetLayer);
}
foreach (var layer in affectedLayers)
RebuildLayerConnections(vm, layer);
}
/// <summary>
/// Pre-computed lookup maps for layer hierarchy traversal.
/// </summary>
private readonly struct LayerLookups(
Dictionary<AnimGraphNode, AnimGraphLayer> nodeToLayer,
Dictionary<string, AnimGraphLayer> machineToLayer)
{
public Dictionary<AnimGraphNode, AnimGraphLayer> NodeToLayer { get; } = nodeToLayer;
public Dictionary<string, AnimGraphLayer> MachineToLayer { get; } = machineToLayer;
}
/// <summary>
/// Builds the lookup maps needed by <see cref="FindOwnerRootLayer"/> and
/// <see cref="GetAncestorRootLayer"/>. Call once and reuse for multiple queries.
/// </summary>
private static LayerLookups BuildLayerLookups(AnimGraphViewModel vm)
{
var nodeToLayer = new Dictionary<AnimGraphNode, AnimGraphLayer>();
foreach (var layer in vm.Layers)
foreach (var node in layer.Nodes)
nodeToLayer.TryAdd(node, layer);
foreach (var (_, subGraph) in vm.StateSubGraphs)
foreach (var node in subGraph.Nodes)
nodeToLayer.TryAdd(node, subGraph);
var machineToLayer = new Dictionary<string, AnimGraphLayer>(StringComparer.OrdinalIgnoreCase);
foreach (var layer in vm.Layers)
foreach (var node in layer.Nodes)
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var mn))
machineToLayer.TryAdd(mn, layer);
foreach (var (_, subGraph) in vm.StateSubGraphs)
foreach (var node in subGraph.Nodes)
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var mn))
machineToLayer.TryAdd(mn, subGraph);
return new LayerLookups(nodeToLayer, machineToLayer);
}
/// <summary>
/// Finds the correct animation blueprint layer (_Root layer) for a SaveCachedPose node
/// by tracing ALL downstream UseCachedPose consumers through the state machine
/// hierarchy to find each consumer's ancestor _Root layer. If all consumers trace
/// to the same _Root layer, that layer is used. If consumers span different _Root
/// layers, the SaveCachedPose must be placed in the outermost layer (AnimGraph).
/// Returns null if no suitable layer is found.
/// </summary>
private static AnimGraphLayer? FindOwnerRootLayer(
AnimGraphNode saveNode, AnimGraphViewModel vm, LayerLookups lookups)
{
// Collect ancestor _Root layers from ALL consumers
var consumerRootLayers = new HashSet<AnimGraphLayer>();
foreach (var conn in vm.Connections)
{
if (conn.SourceNode != saveNode) continue;
if (!lookups.NodeToLayer.TryGetValue(conn.TargetNode, out var consumerLayer))
continue;
var rootLayer = GetAncestorRootLayer(consumerLayer, lookups.MachineToLayer);
if (rootLayer != null)
consumerRootLayers.Add(rootLayer);
}
if (consumerRootLayers.Count == 0)
return null;
// If all consumers trace to the same _Root layer, use it
if (consumerRootLayers.Count == 1)
return consumerRootLayers.First();
// Consumers span different _Root layers: the SaveCachedPose must be placed
// in the outermost animation blueprint layer (AnimGraph) since it needs to
// be accessible from all consumer layers. Return null to trigger fallback
// to the outermost layer.
return null;
}
/// <summary>
/// Walks up the layer hierarchy to find the nearest ancestor animation blueprint
/// layer (one containing a _Root node). For state sub-graphs, traces through the
/// BelongsToStateMachine → StateMachineName chain to find the parent layer.
/// </summary>
private static AnimGraphLayer? GetAncestorRootLayer(
AnimGraphLayer layer,
Dictionary<string, AnimGraphLayer> machineToLayer)
{
var visited = new HashSet<AnimGraphLayer>();
var current = layer;
while (current != null && visited.Add(current))
{
// If this layer contains a _Root node, it's an animation blueprint layer
if (current.Nodes.Any(n => n.ExportType.EndsWith("_Root", StringComparison.OrdinalIgnoreCase)))
return current;
// Find the state machine this sub-graph belongs to
string? smName = null;
foreach (var node in current.Nodes)
{
if (node.AdditionalProperties.TryGetValue("BelongsToStateMachine", out var val) &&
!string.IsNullOrEmpty(val))
{
smName = val;
break;
}
}
if (string.IsNullOrEmpty(smName) || !machineToLayer.TryGetValue(smName, out current))
break;
}
return null;
}
/// <summary>
/// Rebuilds a layer's connection list from vm.Connections based on which
/// nodes are currently in the layer, then re-runs layout.
/// </summary>
private static void RebuildLayerConnections(AnimGraphViewModel vm, AnimGraphLayer layer)
{
var nodeSet = new HashSet<AnimGraphNode>(layer.Nodes);
layer.Connections.Clear();
foreach (var conn in vm.Connections)
{
if (nodeSet.Contains(conn.SourceNode) && nodeSet.Contains(conn.TargetNode))
layer.Connections.Add(conn);
}
LayoutLayerNodes(layer);
}
/// <summary>
/// Collects a root node and all its upstream providers via BFS.
/// When <paramref name="excludeNode"/> is provided, matching upstream nodes
/// are skipped (not collected and their inputs are not traversed).
/// </summary>
private static List<AnimGraphNode> CollectUpstream(
AnimGraphNode root,
Dictionary<AnimGraphNode, List<AnimGraphNode>> upstreamOf,
HashSet<AnimGraphNode> assigned,
Func<AnimGraphNode, bool>? excludeNode = null)
{
var nodes = new List<AnimGraphNode> { root };
var queue = new Queue<AnimGraphNode>();
queue.Enqueue(root);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var upstream in upstreamOf[current])
{
if (excludeNode != null && excludeNode(upstream))
continue;
if (assigned.Add(upstream))
{
nodes.Add(upstream);
queue.Enqueue(upstream);
}
}
}
return nodes;
}
/// <summary>
/// Creates an <see cref="AnimGraphLayer"/> from a set of nodes, assigns
/// the relevant connections, lays out the nodes and adds the layer to the VM.
/// </summary>
private static void AddLayer(AnimGraphViewModel vm, List<AnimGraphNode> nodes, int index)
{
var nodeSet = new HashSet<AnimGraphNode>(nodes);
var layer = new AnimGraphLayer { Name = GetLayerName(nodes, index) };
layer.Nodes.AddRange(nodes);
foreach (var conn in vm.Connections)
{
if (nodeSet.Contains(conn.SourceNode) && nodeSet.Contains(conn.TargetNode))
layer.Connections.Add(conn);
}
LayoutLayerNodes(layer);
vm.Layers.Add(layer);
}
/// <summary>
/// Creates an <see cref="AnimGraphLayer"/> for a state machine state sub-graph
/// and stores it in <see cref="AnimGraphViewModel.StateSubGraphs"/> keyed by
/// <paramref name="rootNodePropName"/> (the root node's property name from StateRootNodeIndex).
/// </summary>
private static void AddStateSubGraph(AnimGraphViewModel vm, List<AnimGraphNode> nodes, string rootNodePropName, int index)
{
var nodeSet = new HashSet<AnimGraphNode>(nodes);
var layer = new AnimGraphLayer { Name = GetLayerName(nodes, index) };
layer.Nodes.AddRange(nodes);
foreach (var conn in vm.Connections)
{
if (nodeSet.Contains(conn.SourceNode) && nodeSet.Contains(conn.TargetNode))
layer.Connections.Add(conn);
}
LayoutLayerNodes(layer);
vm.StateSubGraphs[rootNodePropName] = layer;
}
/// <summary>
/// Renames state machine internal layers with a parent path prefix
/// (e.g., "AnimGraph > Locomotion" for the overview, or
/// "AnimGraph > Locomotion > Idle" for per-state sub-graphs).
/// </summary>
private static void PrefixStateMachineLayerNames(AnimGraphViewModel vm)
{
// Map: machineName → parent layer name (where the StateMachine node lives)
var smParentLayer = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var layer in vm.Layers)
{
foreach (var node in layer.Nodes)
{
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var machineName))
smParentLayer.TryAdd(machineName, layer.Name);
}
}
// Iteratively rename state sub-graphs and discover nested StateMachine nodes.
// Each pass renames sub-graphs whose parent SM is already known, then registers
// any nested SM nodes found in the newly-renamed sub-graphs for the next pass.
bool changed = true;
while (changed)
{
changed = false;
foreach (var (_, layer) in vm.StateSubGraphs)
{
var smName = string.Empty;
foreach (var node in layer.Nodes)
{
if (node.AdditionalProperties.TryGetValue("BelongsToStateMachine", out var val) &&
!string.IsNullOrEmpty(val))
{
smName = val;
break;
}
}
if (string.IsNullOrEmpty(smName))
continue;
if (!smParentLayer.TryGetValue(smName, out var parentName))
continue;
// Per-state layers: use the _StateResult node's Name additional property for display
var stateResultNode = layer.Nodes.FirstOrDefault(n =>
n.ExportType.EndsWith("_StateResult", StringComparison.OrdinalIgnoreCase));
var stateName = stateResultNode?.AdditionalProperties.GetValueOrDefault("Name") ?? layer.Name;
var expectedName = $"{parentName}{SubGraphPathSeparator}{smName}{SubGraphPathSeparator}{stateName}";
if (layer.Name != expectedName)
{
layer.Name = expectedName;
changed = true;
}
// Register any nested StateMachine nodes within this sub-graph
foreach (var node in layer.Nodes)
{
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var nestedMachineName))
{
if (smParentLayer.TryAdd(nestedMachineName, layer.Name))
changed = true;
}
}
}
}
}
/// <summary>
/// Creates state machine overview layers with synthetic Entry + State nodes
/// and transition connections between states, providing a UE-like state machine
/// editor view. The overview layer is named with the path prefix to match
/// double-click navigation from StateMachine nodes.
/// </summary>
private static void BuildStateMachineOverviewLayers(AnimGraphViewModel vm, List<StateMachineMetadata> smMetadata)
{
// Map: machineName → parent layer name (where the StateMachine node lives)
var smParentLayer = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var layer in vm.Layers)
{
foreach (var node in layer.Nodes)
{
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var machineName))
smParentLayer.TryAdd(machineName, layer.Name);
}
}
// Also scan state sub-graphs for nested StateMachine nodes (already renamed by PrefixStateMachineLayerNames)
foreach (var (_, layer) in vm.StateSubGraphs)
{
foreach (var node in layer.Nodes)
{
if (node.AdditionalProperties.TryGetValue("StateMachineName", out var machineName))
smParentLayer.TryAdd(machineName, layer.Name);
}
}
foreach (var sm in smMetadata)
{
if (sm.StateNames.Count == 0) continue;
// Determine the path-prefixed layer name
var parentName = smParentLayer.GetValueOrDefault(sm.MachineName, "AnimGraph");
var overviewLayerName = $"{parentName}{SubGraphPathSeparator}{sm.MachineName}";
// State sub-graphs are now in StateSubGraphs (no need to remove from Layers)
var overviewLayer = new AnimGraphLayer { Name = overviewLayerName };
var stateNodes = new List<AnimGraphNode>();
// Create Entry node
var entryNode = new AnimGraphNode
{
Name = "Entry",
ExportType = "Entry",
IsEntryNode = true
};
entryNode.Pins.Add(new AnimGraphPin
{
PinName = "Output",
IsOutput = true,
PinType = "transition",
OwnerNode = entryNode
});
overviewLayer.Nodes.Add(entryNode);
// Create State nodes
for (var i = 0; i < sm.StateNames.Count; i++)
{
var stateNode = new AnimGraphNode
{
Name = sm.StateNames[i],
ExportType = "State",
IsStateMachineState = true
};
// Store root node property name for StateRootNodeIndex-based lookup
if (i < sm.StateRootPropNames.Count && !string.IsNullOrEmpty(sm.StateRootPropNames[i]))
stateNode.AdditionalProperties["StateRootNodeName"] = sm.StateRootPropNames[i];
stateNode.Pins.Add(new AnimGraphPin
{
PinName = "In",
IsOutput = false,
PinType = "transition",
OwnerNode = stateNode
});
stateNode.Pins.Add(new AnimGraphPin
{
PinName = "Out",
IsOutput = true,
PinType = "transition",
OwnerNode = stateNode
});
stateNodes.Add(stateNode);
overviewLayer.Nodes.Add(stateNode);
}
// Entry connects to first state (state index 0)
if (stateNodes.Count > 0)
{
overviewLayer.Connections.Add(new AnimGraphConnection
{
SourceNode = entryNode,
SourcePinName = "Output",
TargetNode = stateNodes[0],
TargetPinName = "In"
});
}
// Transition connections between states
foreach (var (prevIdx, nextIdx, transProps) in sm.Transitions)
{
if (prevIdx < stateNodes.Count && nextIdx < stateNodes.Count)
{
var conn = new AnimGraphConnection
{
SourceNode = stateNodes[prevIdx],
SourcePinName = "Out",
TargetNode = stateNodes[nextIdx],
TargetPinName = "In"
};
foreach (var (k, v) in transProps)
conn.TransitionProperties[k] = v;
overviewLayer.Connections.Add(conn);
}
}
// Layout state nodes in a grid arrangement
LayoutStateMachineOverview(overviewLayer, entryNode, stateNodes);
vm.Layers.Add(overviewLayer);
}
}
/// <summary>
/// Arranges state machine overview nodes: Entry on the left, state nodes in a grid.
/// </summary>
private static void LayoutStateMachineOverview(AnimGraphLayer layer, AnimGraphNode entryNode, List<AnimGraphNode> stateNodes)
{
// Place Entry on the far left
entryNode.NodePosX = 0;
entryNode.NodePosY = 0;
if (stateNodes.Count == 0) return;
// Arrange state nodes in a grid to the right of Entry
var cols = Math.Max(1, (int)Math.Ceiling(Math.Sqrt(stateNodes.Count)));
for (var i = 0; i < stateNodes.Count; i++)
{
var col = i % cols;
var row = i / cols;
stateNodes[i].NodePosX = StateNodeHorizontalSpacing + col * StateNodeHorizontalSpacing;
stateNodes[i].NodePosY = row * StateNodeVerticalSpacing;
}
// Center Entry vertically relative to state nodes
var maxRow = (stateNodes.Count - 1) / cols;
entryNode.NodePosY = maxRow * StateNodeVerticalSpacing / 2;
}
/// <summary>
/// Determines a display name for a layer based on the types of nodes it contains.
/// Animation blueprint layers use the _Root node's "Name" property (e.g., "AnimGraph").
/// State machine state sub-graphs use the _StateResult root node's unique property name
/// (from StateRootNodeIndex) to avoid duplicate name collisions.
/// </summary>
private static string GetLayerName(List<AnimGraphNode> nodes, int index)
{
// Animation blueprint layers: use _Root node's Name property
var rootNode = nodes.FirstOrDefault(n =>
n.ExportType.EndsWith("_Root", StringComparison.OrdinalIgnoreCase) &&
n.AdditionalProperties.TryGetValue("Name", out _));
if (rootNode != null &&
rootNode.AdditionalProperties.TryGetValue("Name", out var rootName) &&
!string.IsNullOrEmpty(rootName))
return rootName;
// State machine state sub-graphs: use the _StateResult root node's property name
// (unique identifier from StateRootNodeIndex, avoids duplicate state name collisions)
var stateResultNode = nodes.FirstOrDefault(n =>
n.ExportType.EndsWith("_StateResult", StringComparison.OrdinalIgnoreCase));
if (stateResultNode != null)
return stateResultNode.Name;
// Check if any node belongs to a baked state machine
var smNode = nodes.FirstOrDefault(n =>
n.AdditionalProperties.TryGetValue("BelongsToStateMachine", out _));
if (smNode != null &&
smNode.AdditionalProperties.TryGetValue("BelongsToStateMachine", out var smName) &&
!string.IsNullOrEmpty(smName))
return smName;
var stateMachine = nodes.FirstOrDefault(n =>
n.ExportType.Contains("StateMachine", StringComparison.OrdinalIgnoreCase));
if (stateMachine != null)
return $"StateMachine ({stateMachine.Name})";
var blend = nodes.FirstOrDefault(n =>
n.ExportType.Contains("Blend", StringComparison.OrdinalIgnoreCase));
if (blend != null)
return $"Blend ({blend.Name})";
if (nodes.Count == 1)
return GetShortTypeName(nodes[0].ExportType);
return $"Layer {index}";
}
private static string GetShortTypeName(string exportType)
{
if (exportType.StartsWith("FAnimNode_"))
return exportType["FAnimNode_".Length..];
if (exportType.StartsWith("AnimNode_"))
return exportType["AnimNode_".Length..];
return exportType;
}
/// <summary>
/// Reads BakedStateMachines from the animation blueprint class to associate
/// FAnimNode_StateMachine nodes with their machine names, mark internal
/// state root nodes, and collect state/transition metadata for overview layers.
/// </summary>
private static void AssociateStateMachineNames(UClass animBlueprintClass, UObject? cdo,
List<(string name, string structType)> animNodeProps,
Dictionary<string, AnimGraphNode> nodeByName,
List<StateMachineMetadata> smMetadata)
{
// BakedStateMachines is a UPROPERTY on UAnimBlueprintGeneratedClass
// Try reading from both the class and CDO
UScriptArray? bakedMachines = null;
if (animBlueprintClass.TryGetValue(out UScriptArray classBaked, "BakedStateMachines"))
bakedMachines = classBaked;
else if (cdo != null && cdo.TryGetValue(out UScriptArray cdoBaked, "BakedStateMachines"))
bakedMachines = cdoBaked;
if (bakedMachines == null || bakedMachines.Properties.Count == 0)
return;
for (var machineIdx = 0; machineIdx < bakedMachines.Properties.Count; machineIdx++)
{
if (bakedMachines.Properties[machineIdx].GetValue(typeof(FStructFallback)) is not FStructFallback machineStruct)
continue;
// Extract MachineName
var machineName = string.Empty;
foreach (var prop in machineStruct.Properties)
{
if (prop.Name.Text == "MachineName")
{
machineName = prop.Tag?.GenericValue?.ToString() ?? string.Empty;
break;
}
}
if (string.IsNullOrEmpty(machineName))
continue;
// Associate FAnimNode_StateMachine nodes that reference this machine index
var machineIdxStr = machineIdx.ToString();
foreach (var (propName, structType) in animNodeProps)
{
if (!structType.Contains("StateMachine", StringComparison.OrdinalIgnoreCase))
continue;
if (!nodeByName.TryGetValue(propName, out var smNode))
continue;
if (!smNode.AdditionalProperties.TryGetValue("StateMachineIndexInClass", out var idxStr))
continue;
if (idxStr == machineIdxStr)
smNode.AdditionalProperties["StateMachineName"] = machineName;
}
var metadata = new StateMachineMetadata { MachineName = machineName };
// Extract state names and mark root nodes with BelongsToStateMachine
foreach (var prop in machineStruct.Properties)
{
if (prop.Name.Text != "States") continue;
if (prop.Tag?.GenericValue is not UScriptArray states) break;
for (var stateIdx = 0; stateIdx < states.Properties.Count; stateIdx++)
{
if (states.Properties[stateIdx].GetValue(typeof(FStructFallback)) is not FStructFallback stateStruct)
{
metadata.StateNames.Add($"State_{stateIdx}");
metadata.StateRootPropNames.Add(string.Empty);
continue;
}
// Extract state name
var stateName = $"State_{stateIdx}";
if (stateStruct.TryGetValue(out FName stateNameProp, "StateName"))
stateName = stateNameProp.Text;
metadata.StateNames.Add(stateName);
// Mark root node via StateRootNodeIndex
// UE stores node indices in reverse order relative to ChildProperties,
// so the actual index into animNodeProps is (Count - 1 - stateRootIndex).
if (!stateStruct.TryGetValue(out int stateRootIndex, "StateRootNodeIndex") ||
stateRootIndex < 0 || stateRootIndex >= animNodeProps.Count)
{
metadata.StateRootPropNames.Add(string.Empty);
continue;
}
var mappedIndex = animNodeProps.Count - 1 - stateRootIndex;
var rootPropName = animNodeProps[mappedIndex].name;
metadata.StateRootPropNames.Add(rootPropName);
if (nodeByName.TryGetValue(rootPropName, out var rootNode))
rootNode.AdditionalProperties["BelongsToStateMachine"] = machineName;
}
break;
}
// Extract machine-level transitions (PreviousState → NextState)
foreach (var prop in machineStruct.Properties)
{
if (prop.Name.Text != "Transitions") continue;
if (prop.Tag?.GenericValue is not UScriptArray transitions) break;
foreach (var transProp in transitions.Properties)
{
if (transProp.GetValue(typeof(FStructFallback)) is not FStructFallback transStruct)
continue;
if (!transStruct.TryGetValue(out int previousState, "PreviousState"))
continue;
if (!transStruct.TryGetValue(out int nextState, "NextState"))
continue;
if (previousState >= 0 && nextState >= 0 &&
previousState < metadata.StateNames.Count && nextState < metadata.StateNames.Count)
{
var transProps = new Dictionary<string, string>();
foreach (var tp in transStruct.Properties)
{
var name = tp.Name.Text;
if (name is "PreviousState" or "NextState") continue;
var val = tp.Tag?.GenericValue?.ToString();
if (!string.IsNullOrEmpty(val))
transProps[name] = val.Length > MaxPropertyValueDisplayLength
? val[..MaxPropertyValueDisplayLength] + "…" : val;
}
metadata.Transitions.Add((previousState, nextState, transProps));
}
}
break;
}
smMetadata.Add(metadata);
}
}
/// <summary>
/// Arranges nodes within a layer in a left-to-right flow layout
/// based on connection topology (sinks on the left, sources on the right).
/// </summary>
private static void LayoutLayerNodes(AnimGraphLayer layer)
{
if (layer.Nodes.Count == 0) return;
// Build directed adjacency: target -> sources (who feeds into target)
var incomingEdges = new Dictionary<AnimGraphNode, List<AnimGraphNode>>();
var outgoingEdges = new Dictionary<AnimGraphNode, List<AnimGraphNode>>();
foreach (var node in layer.Nodes)
{
incomingEdges[node] = [];
outgoingEdges[node] = [];
}
foreach (var conn in layer.Connections)
{
// SourceNode's output feeds into TargetNode's input
outgoingEdges[conn.SourceNode].Add(conn.TargetNode);
incomingEdges[conn.TargetNode].Add(conn.SourceNode);
}
// Topological sort to assign depth levels (longest path from leaves)
var depth = new Dictionary<AnimGraphNode, int>();
var layerSet = new HashSet<AnimGraphNode>(layer.Nodes);
// Find sink nodes (nodes with no outgoing edges within this layer)
var sinkNodes = layer.Nodes.Where(n => outgoingEdges[n].Count == 0).ToList();
// BFS from sinks to assign depth
foreach (var node in layer.Nodes)
depth[node] = 0;
var queue = new Queue<AnimGraphNode>();
foreach (var sink in sinkNodes)
{
queue.Enqueue(sink);
}
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var source in incomingEdges[current])
{
var newDepth = depth[current] + 1;
if (newDepth > depth[source])
{
depth[source] = newDepth;
queue.Enqueue(source);
}
}
}
// Group by depth level and assign positions
var maxDepth = depth.Values.DefaultIfEmpty(0).Max();
var nodesAtDepth = new Dictionary<int, List<AnimGraphNode>>();
foreach (var (node, d) in depth)
{
if (!nodesAtDepth.TryGetValue(d, out var list))
nodesAtDepth[d] = list = [];
list.Add(node);
}
// Position: sources (high depth) on the right, sinks (depth 0) on the left
for (var d = 0; d <= maxDepth; d++)
{
if (!nodesAtDepth.TryGetValue(d, out var nodesInColumn)) continue;
var x = (maxDepth - d) * NodeHorizontalSpacing;
for (var i = 0; i < nodesInColumn.Count; i++)
{
nodesInColumn[i].NodePosX = x;
nodesInColumn[i].NodePosY = i * NodeVerticalSpacing;
}
}
}
private static bool IsAnimNodeStruct(string name)
{
return name.StartsWith("FAnimNode_", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("AnimNode_", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("AnimGraphNode_", StringComparison.OrdinalIgnoreCase);
}
private static bool IsSaveCachedPoseNode(AnimGraphNode node)
{
return node.ExportType.Contains("SaveCachedPose", StringComparison.OrdinalIgnoreCase) ||
node.Name.Contains("SaveCachedPose", StringComparison.OrdinalIgnoreCase);
}
private static void ExtractNodeProperties(UObject cdo, string propName, AnimGraphNode node)
{
// Try to get the struct fallback value for this node property
if (!cdo.TryGetValue(out FStructFallback structValue, propName))
return;
// Extract useful properties from the struct
foreach (var prop in structValue.Properties)
{
var name = prop.Name.Text;
var value = prop.Tag?.GenericValue?.ToString() ?? string.Empty;
switch (name)
{
case "NodeComment":
node.NodeComment = value;
break;
default:
// Store additional properties for display
if (value.Length <= MaxPropertyValueDisplayLength)
node.AdditionalProperties[name] = value;
break;
}
}
// Add input pins based on struct properties that reference other poses/nodes
foreach (var prop in structValue.Properties)
{
var name = prop.Name.Text;
// Properties referencing other animation poses are connections
if (IsPoseProperty(name) || IsLinkedNodeProperty(name))
{
node.Pins.Add(new AnimGraphPin
{
PinName = name,
IsOutput = false,
PinType = "pose",
OwnerNode = node
});
}
}
}
private static bool IsPoseProperty(string name)
{
return name.Contains("Pose", StringComparison.OrdinalIgnoreCase) &&
!name.Contains("PoseSnapshot", StringComparison.OrdinalIgnoreCase);
}
private static bool IsLinkedNodeProperty(string name)
{
return name.Equals("BasePose", StringComparison.OrdinalIgnoreCase) ||
name.Equals("InputPose", StringComparison.OrdinalIgnoreCase) ||
name.Equals("SourcePose", StringComparison.OrdinalIgnoreCase) ||
name.Equals("ComponentPose", StringComparison.OrdinalIgnoreCase) ||
name.Contains("LinkedAnimGraph", StringComparison.OrdinalIgnoreCase);
}
private static void ResolveConnections(UObject cdo, List<(string name, string structType)> animNodeProps,
Dictionary<string, AnimGraphNode> nodeByName, AnimGraphViewModel vm)
{
// Animation node connections in cooked assets are encoded via
// FPoseLink / FComponentSpacePoseLink struct properties within each node.
// These contain a "LinkID" integer that maps to the index of the target node
// in the class's animation node property list.
foreach (var (propName, _) in animNodeProps)
{
if (!cdo.TryGetValue(out FStructFallback structValue, propName))
continue;
if (!nodeByName.TryGetValue(propName, out var sourceNode))
continue;
foreach (var prop in structValue.Properties)
{
var tag = prop.Tag;
if (tag == null) continue;
// Check if this property is a pose link (FPoseLink or FComponentSpacePoseLink)
TryResolvePoseLink(tag, prop.Name.Text, sourceNode, animNodeProps, nodeByName, vm);
}
}
}
private static void TryResolvePoseLink(FPropertyTagType tag, string pinName,
AnimGraphNode sourceNode, List<(string name, string structType)> animNodeProps,
Dictionary<string, AnimGraphNode> nodeByName, AnimGraphViewModel vm)
{
// Handle arrays of pose links (e.g., BlendPose TArray<FPoseLink>)
if (tag.GenericValue is UScriptArray array)
{
for (var i = 0; i < array.Properties.Count; i++)
{
TryResolvePoseLink(array.Properties[i], $"{pinName}[{i}]", sourceNode, animNodeProps, nodeByName, vm);
}
return;
}
// A PoseLink/ComponentSpacePoseLink is a struct with a LinkID property
if (tag.GetValue(typeof(FStructFallback)) is not FStructFallback linkStruct)
return;
if (!linkStruct.TryGetValue(out int linkId, "LinkID"))
return;
// LinkID of -1 means not connected
if (linkId < 0 || linkId >= animNodeProps.Count)
return;
var targetPropName = animNodeProps[linkId].name;
// Avoid self-connections
if (targetPropName == sourceNode.Name) return;
if (!nodeByName.TryGetValue(targetPropName, out var targetNode))
return;
vm.Connections.Add(new AnimGraphConnection
{
SourceNode = targetNode,
SourcePinName = "Output",
TargetNode = sourceNode,
TargetPinName = pinName
});
}
}