Code/Target/AnimGraph2Writer.cs

Converts an AnimGraph1 KV3 graph into an AnimGraph2 .ag JSON document. Writes parameters, state machines, transitions and conditions, generating deterministic GUID identifiers by hashing the model path and a counter.

#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;
    }
}