Code/HumanoidRetargeter/Core/Target/StockAnimationGraph.cs

Utility class for managing stock humanoid animation graphs. It builds a catalog of replaceable animation slots, constructs graph and subgraph paths, attaches or queries a graph on a VMDL, copies a graph for a preview model, and rewrites sequence references inside a graph and its subgraphs to point at replacement sequences.

File Access
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;

namespace HumanoidRetargeter.Core.Target;

/// <summary>Changes sequence references, not graph connections, parameters, IK or additive layers.</summary>
public static class StockAnimationGraph
{
    public static IReadOnlyList<StockAnimationSlot> Slots { get; } = BuildSlots();

    private static StockAnimationSlot[] BuildSlots()
    {
        var slots = new List<StockAnimationSlot>
        {
            new("idle", "Idle (base pose)", true, new[] { "IdlePose_Default" },
                "Idle is a base pose: single-frame graph nodes use the clip's first frame. Animated idle layers and weapon poses stay unchanged."),
            new("jump", "Jump (standing)", false, new[] { "Jump_Standing" },
                "Replaces take-off only. Airborne and landing states stay unchanged; match the stock timing and check vertical motion."),
            new("jump_crouch", "Jump (crouching)", false, new[] { "Jump_Crouching" },
                "Replaces crouched take-off only; airborne and landing states stay unchanged.")
        };
        // Each direction is blended from several speed rings. The forward run, for one, holds
        // Run_N at ~180 and Sprint_N at ~300, so a controller running at 320 plays only the
        // sprint ring: replacing Run_N alone left a replaced run that never showed in game.
        // A slot therefore takes over its direction on every ring of its gait.
        var tiers = new Dictionary<string, string[]>
        {
            ["Walk"] = new[] { "Walk_{0}", "WalkFast_{0}", "Walk2X_{0}" },
            ["Run"] = new[] { "Run_{0}", "Run_{0}_m", "Run_{0}_f", "Run2X_{0}", "Sprint_{0}", "Sprint_{0}_m", "Sprint_{0}_f" },
            ["CrouchWalk"] = new[] { "CrouchWalk_{0}", "CrouchWalkLow_{0}" },
        };
        foreach (var (stem, label) in new[] { ("Walk", "Walk"), ("Run", "Run"), ("CrouchWalk", "Crouch walk") })
        foreach (var (direction, title) in new[] { ("N", "forward"), ("S", "backward"), ("E", "right"), ("W", "left"),
            ("NE", "forward-right"), ("NW", "forward-left"), ("SE", "backward-right"), ("SW", "backward-left") })
        {
            var sequences = tiers[stem].Select(t => string.Format(t, direction)).ToArray();
            slots.Add(new(stem.ToLowerInvariant() + "_" + direction.ToLowerInvariant(), label + " " + title, true, sequences,
                "Replaces this direction at every speed of the gait (for a run: run, fast run and sprint), so the clip plays at any movement speed. "
                + "Other directions, blend thresholds and foot-sync settings stay unchanged. Use a matching looping clip; review foot sliding and transitions."));
        }
        return slots.ToArray();
    }

    public static string GraphPath(string outputFolder, string modelName)
        => (string.IsNullOrEmpty(outputFolder) ? "" : outputFolder.TrimEnd('/', '\\') + "/") + "graphs/" + modelName + ".vanmgrph";

    public static string Attach(string vmdl, string graphPath)
    {
        var doc = Kv3.Parse(vmdl);
        ((KvObject)((KvObject)doc.Root)["rootNode"])["anim_graph_name"] = new KvString(graphPath);
        return Kv3.Serialize(doc);
    }

    public static string GraphName(string vmdl)
        => ((KvObject)((KvObject)Kv3.Parse(vmdl).Root)["rootNode"]).GetString("anim_graph_name") ?? "";

    /// <summary>Copies all graph logic and settings, changing only its editor preview model.</summary>
    public static string CopyForModel(string graph, string modelPath)
    {
        var doc = Parse(graph);
        SetPreview((KvObject)doc.Root, modelPath);
        return Kv3.Serialize(doc);
    }

    public static string Replace(string graph, StockAnimationSlot slot, string sequence, string modelPath, out int references)
    {
        var result = Replace(graph, slot, sequence, modelPath, _ => null, "");
        references = result.References;
        return result.Graph;
    }

    /// <summary>
    /// Points the slot's sequence references at <paramref name="sequence"/>, following subgraph
    /// nodes: the stock Citizen graph keeps all of its animation in subgraphs
    /// (citizen_core, citizen_locomotion, ...), so the entry graph alone holds no sequence. A
    /// subgraph that changes is copied to <paramref name="subgraphFolder"/> and its parent is
    /// pointed at the copy; shipped subgraphs are never modified. <paramref name="readSubgraph"/>
    /// returns a subgraph's source by asset path (the project copy first), or null.
    /// </summary>
    public static StockGraphReplacement Replace(string graph, StockAnimationSlot slot, string sequence, string modelPath,
        Func<string, string?> readSubgraph, string subgraphFolder)
    {
        var doc = Parse(graph);
        var outputs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        var visited = new Dictionary<string, (string Path, int Count)>(StringComparer.OrdinalIgnoreCase);
        var folder = subgraphFolder.Replace('\\', '/').TrimEnd('/');

        int Rewrite(KvValue root, HashSet<string> chain)
        {
            var changed = 0;
            Visit(root, node =>
            {
                var name = node.GetString("m_sequenceName");
                if (name is not null && (slot.Sequences.Contains(name, StringComparer.Ordinal)
                    || name.StartsWith(slot.ReplacementPrefix, StringComparison.Ordinal)))
                {
                    node["m_sequenceName"] = new KvString(sequence);
                    changed++;
                }

                var reference = node.GetString("m_subGraphFilename")?.Replace('\\', '/');
                if (string.IsNullOrEmpty(reference) || chain.Contains(reference))
                    return;
                if (!visited.TryGetValue(reference, out var copy))
                {
                    copy = (reference, 0);
                    if (readSubgraph(reference) is { } text)
                    {
                        var sub = Kv3.Parse(text);
                        chain.Add(reference);
                        var count = Rewrite(sub.Root, chain);
                        chain.Remove(reference);
                        if (count > 0)
                        {
                            var owned = folder.Length > 0 && reference.StartsWith(folder + "/", StringComparison.OrdinalIgnoreCase)
                                ? reference
                                : (folder.Length > 0 ? folder + "/" : "") + reference[(reference.LastIndexOf('/') + 1)..];
                            outputs[owned] = Kv3.Serialize(sub);
                            copy = (owned, count);
                        }
                    }
                    visited[reference] = copy;
                }
                if (copy.Count > 0)
                {
                    node["m_subGraphFilename"] = new KvString(copy.Path);
                    changed += copy.Count;
                }
            });
            return changed;
        }

        var references = Rewrite(doc.Root, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
        if (references == 0)
            throw new InvalidOperationException($"This graph has no compatible '{slot.Label}' sequence references. Its structure may have been customized; automatic replacement was not applied.");
        SetPreview((KvObject)doc.Root, modelPath);
        return new StockGraphReplacement(Kv3.Serialize(doc), outputs, references);
    }

    /// <summary>Where a model's project-owned subgraph copies live, beside its graph.</summary>
    public static string SubgraphFolder(string outputFolder, string modelName)
        => (string.IsNullOrEmpty(outputFolder) ? "" : outputFolder.TrimEnd('/', '\\') + "/") + "graphs/" + modelName + "_subgraphs";

    private static Kv3Document Parse(string text)
    {
        var doc = Kv3.Parse(text);
        if (doc.Root is not KvObject root || root.GetString("_class") != "CAnimationGraph")
            throw new InvalidOperationException("Expected an editable s&box animation graph source (.vanmgrph).");
        return doc;
    }

    private static void SetPreview(KvObject root, string modelPath)
    {
        var models = new KvArray();
        models.Items.Add(new KvString(modelPath));
        root["m_previewModels"] = models;
        root["m_boneMergeModels"] = new KvArray(); // do not dress the custom model in Citizen preview clothing
    }

    private static void Visit(KvValue value, Action<KvObject> action)
    {
        if (value is KvObject obj)
        {
            action(obj);
            foreach (var key in obj.Keys) Visit(obj[key], action);
        }
        else if (value is KvArray array)
            foreach (var item in array.Items) Visit(item, action);
    }
}