Converts an AnimGraph1 KV3 graph into an AnimGraph2 .ag JSON document. Builds parameters, a state machine with states/conduits/transitions, clip nodes and condition nodes, and hashes the model path into deterministic node identifiers.
#nullable enable
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HumanoidHandRetargeter.Target;
/// <summary>Writes a graph generated by <see cref="HandAnimGraph"/> or <see cref="WeaponAnimGraph"/> as an AnimGraph2
/// <c>.ag</c> graph with the same parameters, states and transitions. Identifiers come from the model path and the build
/// order, so the same input always produces the same file.</summary>
public static class AnimGraph2Writer
{
private const string ResultOutput = "Result";
private const string ParameterOutput = "Value";
private const string FinishedAtEnd = "FinishedConditionOption_OnFinished";
private const int ComparisonEqual = 0;
private const int Column = 320;
private const int Row = 130;
/// <summary>The <c>.ag</c> JSON for <paramref name="animGraph1"/> (AnimGraph1 KV3 text), animating
/// <paramref name="model"/> (a vmdl) on <paramref name="skeleton"/> (a <c>.skel</c>). <paramref name="animations"/> maps
/// each sequence the graph plays to its <c>.anim</c>. Throws when a sequence has no animation or the graph uses a node,
/// parameter or condition the generators don't produce.</summary>
public static string Write(string animGraph1, string model, string skeleton, IReadOnlyDictionary<string, string> animations)
{
ArgumentException.ThrowIfNullOrWhiteSpace(animGraph1);
ArgumentException.ThrowIfNullOrWhiteSpace(model);
ArgumentException.ThrowIfNullOrWhiteSpace(skeleton);
ArgumentNullException.ThrowIfNull(animations);
return new Conversion((KvObject)Kv3.Parse(animGraph1).Root, model, animations).Write(skeleton);
}
private sealed class Conversion
{
private readonly Dictionary<long, KvObject> nodes = new();
private readonly Dictionary<long, (string Name, string Identifier)> parameters = new();
private readonly IReadOnlyDictionary<string, string> animations;
private readonly KvObject graph;
private readonly string model;
private int nextId;
public Conversion(KvObject graph, string model, IReadOnlyDictionary<string, string> animations)
{
this.graph = graph; this.model = model; this.animations = animations;
foreach (var entry in Objects(Obj(graph, "m_nodeManager"), "m_nodes"))
nodes[Id(entry, "key")] = Obj(entry, "value");
}
public string Write(string skeleton)
{
var root = nodes.Values.FirstOrDefault(n => Str(n, "_class") == "CRootAnimNode")
?? throw new InvalidOperationException("The AnimGraph1 graph has no root node.");
var document = new JsonObject
{
["Version"] = 1, ["Model"] = model, ["Skeleton"] = skeleton, ["parameters"] = Parameters(),
};
var list = new JsonArray();
var input = Input(root, "m_inputConnection");
var pose = Str(input, "_class") == "CStateMachineAnimNode" ? Add(list, StateMachine(input)) : Clip(list, input);
list.Insert(0, Node("PoseResultNode", Position(Column, 0), ("Pose", pose)));
document["nodes"] = list;
return document.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
}
private JsonArray Parameters()
{
var list = new JsonArray();
foreach (var parameter in Objects(Obj(graph, "m_pParameterList"), "m_Parameters"))
{
var name = Str(parameter, "m_name");
if (Str(parameter, "_class") != "CBoolAnimParameter")
throw new InvalidOperationException($"Parameter '{name}' is a {Str(parameter, "_class")}, which the AnimGraph2 writer doesn't support.");
var identifier = NewId();
parameters[Id(parameter, "m_id")] = (name, identifier);
list.Add(new JsonObject
{
["Identifier"] = identifier, ["Name"] = name,
["RuntimeDefaultBool"] = Bool(parameter, "m_bDefaultValue"), ["Type"] = "Bool",
});
}
return list;
}
private JsonObject StateMachine(KvObject machine)
{
var states = Objects(machine, "m_states").ToList();
if (states.Count == 0) throw new InvalidOperationException("The AnimGraph1 state machine has no states.");
var names = states.ToDictionary(s => Id(s, "m_stateID"), s => Str(s, "m_name"));
var stateList = new JsonArray();
var conduits = new Dictionary<(string From, string To), JsonArray>();
foreach (var state in states)
{
var name = Str(state, "m_name");
var order = new JsonArray();
foreach (var transition in Objects(state, "m_transitions"))
{
var target = names.GetValueOrDefault(Id(transition, "m_destState"))
?? throw new InvalidOperationException($"A transition from '{name}' leads to a state the graph doesn't have.");
if (!conduits.TryGetValue((name, target), out var conduit)) conduits[(name, target)] = conduit = new JsonArray();
order.Add(Transition(conduit, transition, $"{name} to {target}"));
}
var graphNodes = new JsonArray();
var pose = Clip(graphNodes, Input(state, "m_inputConnection"));
graphNodes.Insert(0, Node("PoseResultNode", Position(2 * Column, 0), ("Pose", pose)));
var at = Values(state, "m_position").Cast<KvDouble>().Select(v => v.Value).ToArray();
stateList.Add(new JsonObject
{
["Name"] = name, ["Type"] = "BlendTree", ["Events"] = new JsonArray(), ["TimedEvents"] = new JsonArray(),
["TransitionOrder"] = order, ["Position"] = Position(at[0], at[1]), ["Graph"] = SubGraph(graphNodes, null),
});
}
var start = states.FirstOrDefault(s => Bool(s, "m_bIsStartState")) ?? states[0];
return Node("StateMachineNode", Position(0, 0),
("States", stateList),
("Conduits", new JsonArray(conduits.Select(c => (JsonNode)new JsonObject
{
["From"] = c.Key.From, ["To"] = c.Key.To, ["Graph"] = SubGraph(c.Value, "TransitionConduit"),
}).ToArray())),
("DefaultState", Str(start, "m_name")));
}
// A sequence node becomes a clip playing its .anim.
private JsonObject Clip(JsonArray list, KvObject node)
{
if (Str(node, "_class") != "CSequenceAnimNode")
throw new InvalidOperationException($"Node '{Str(node, "m_sName")}' is a {Str(node, "_class")}, which the AnimGraph2 writer doesn't support.");
var sequence = Str(node, "m_sequenceName");
if (!animations.TryGetValue(sequence, out var animation))
throw new InvalidOperationException($"Sequence '{sequence}' has no AnimGraph2 animation (.anim).");
return Add(list, Node("AnimationClipNode", Position(0, 0),
("Loop", Bool(node, "m_bLoop")), ("SampleRootMotion", false),
("DefaultVariationData", new JsonObject { ["AnimationName"] = animation, ["SpeedMultiplier"] = Number(node, "m_playbackSpeed"), ["StartSyncEventOffset"] = 0 }),
("Overrides", new JsonArray())));
}
// Adds a transition node with its conditions to a conduit graph and returns its identifier.
private string Transition(JsonArray conduit, KvObject transition, string name)
{
var row = conduit.Count;
var conditions = Objects(transition, "m_conditions").Select((c, i) => Condition(conduit, c, (row + i) * Row)).ToList();
var condition = conditions.Count switch
{
0 => Add(conduit, Node("ConstBoolNode", Position(Column, row * Row), ("Value", true))),
1 => conditions[0],
_ => Add(conduit, Node("AndNode", Position(2 * Column, row * Row),
("Conditions", new JsonArray(conditions.Select(c => (JsonNode)new JsonObject { ["Input"] = c }).ToArray())))),
};
var node = Node("TransitionNode", Position(3 * Column, row * Row),
("Condition", condition), ("TransitionName", name), ("Duration", Number(transition, "m_blendDuration")),
("TimeMatch", "None"), ("BlendWeightEasing", "Linear"), ("RootMotionBlend", "Blend"),
// AnimGraph1 takes a transition mid-blend too: a trigger during a blend must not be lost.
("CanBeForced", true));
conduit.Add(node);
return (string)node["Identifier"]!;
}
private JsonObject Condition(JsonArray conduit, KvObject condition, int y)
{
switch (Str(condition, "_class"))
{
case "CFinishedCondition":
return Add(conduit, Node("StateCompletedConditionNode", Position(0, y), ("AtClipEnd", Str(condition, "m_option") == FinishedAtEnd)));
case "CParameterAnimCondition":
var id = Id(condition, "m_paramID");
if (!parameters.TryGetValue(id, out var parameter))
throw new InvalidOperationException($"A condition reads parameter {id}, which the graph doesn't define.");
if (Long(condition, "m_comparisonOp") != ComparisonEqual)
throw new InvalidOperationException($"Parameter '{parameter.Name}' is compared with operator {Long(condition, "m_comparisonOp")}; only equality is supported.");
var value = Add(conduit, Node("BoolParameterNode", Position(0, y),
("ParameterIdentifier", parameter.Identifier), ("ParameterName", parameter.Name)), ParameterOutput);
return Bool(Obj(condition, "m_comparisonValue"), "m_data") ? value : Add(conduit, Node("NotNode", Position(Column, y), ("Value", value)));
default:
throw new InvalidOperationException($"Condition {Str(condition, "_class")} isn't supported by the AnimGraph2 writer.");
}
}
private JsonObject Node(string type, string position, params (string Key, JsonNode? Value)[] properties)
{
var node = new JsonObject { ["_class"] = type };
foreach (var (key, value) in properties) node[key] = value;
node["Identifier"] = NewId();
node["Position"] = position;
node["HandleOffsets"] = new JsonObject();
return node;
}
private static JsonObject Add(JsonArray list, JsonObject node, string output = ResultOutput)
{
list.Add(node);
return new JsonObject { ["Identifier"] = (string)node["Identifier"]!, ["Output"] = output };
}
private static JsonObject SubGraph(JsonArray list, string? kind)
{
var result = new JsonObject { ["Version"] = 1, ["nodes"] = list };
if (kind is not null) result["Kind"] = kind;
return result;
}
private static string Position(double x, double y) => string.Create(CultureInfo.InvariantCulture, $"{x},{y}");
// Stable identifiers, unique in the document: the same graph for the same model always gets the same ones.
private string NewId() => new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{model}/{nextId++}")).AsSpan(0, 16)).ToString();
private KvObject Input(KvObject node, string key)
{
var id = Id(Obj(node, key), "m_nodeID");
return nodes.TryGetValue(id, out var input) ? input : throw new InvalidOperationException($"Node '{node.GetString("m_sName") ?? node.GetString("m_name")}' plays node {id}, which the graph doesn't have.");
}
private static KvObject Obj(KvObject o, string key) => (KvObject)o[key];
private static IEnumerable<KvObject> Objects(KvObject o, string key) => ((KvArray)o[key]).Items.Cast<KvObject>();
private static IEnumerable<KvValue> Values(KvObject o, string key) => ((KvArray)o[key]).Items;
private static string Str(KvObject o, string key) => ((KvString)o[key]).Value;
private static bool Bool(KvObject o, string key) => ((KvBool)o[key]).Value;
private static long Long(KvObject o, string key) => ((KvLong)o[key]).Value;
private static double Number(KvObject o, string key) => ((KvDouble)o[key]).Value;
private static long Id(KvObject o, string key) => ((KvLong)Obj(o, key)["m_id"]).Value;
}
}