HumanoidRetargeter/Target/VmdlAugmenter.cs

Utility that non-destructively augments a vmdl KV3 document by inserting or replacing AnimationList nodes (AnimFile, Folder, 2DBlend etc.), ensures embedded mesh/material nodes, prunes missing-source sequences, groups locomotion sets, and can neutralize pinky constraints. It parses and mutates a Kv3 document model and reserializes the result.

File Access
#nullable enable annotations

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

namespace HumanoidRetargeter.Target;

/// <summary>
/// Thrown by <see cref="VmdlAugmenter.Augment"/> when an animation name collides with an
/// existing AnimationList node that is not an AnimFile (replacing it would destroy user data).
/// </summary>
public sealed class VmdlAugmentException : Exception
{
    /// <summary>One message per colliding entry, naming the sequence and the existing
    /// node's class.</summary>
    public IReadOnlyList<string> Collisions { get; }

    /// <summary>Creates the exception from the collected collision messages.</summary>
    public VmdlAugmentException(IReadOnlyList<string> collisions)
        : base("Cannot augment vmdl, name collisions with non-AnimFile nodes: "
            + string.Join("; ", collisions))
    {
        Collisions = collisions;
    }
}

/// <summary>Options for <see cref="VmdlAugmenter.Augment"/>.</summary>
public sealed class AugmentOptions
{
    /// <summary>
    /// <c>default_root_bone_name</c> used when the vmdl has NO AnimationList yet (the value
    /// also seeds ExtractMotion nodes' <c>root_bone_name</c>) — same value the standalone
    /// writer uses (<see cref="RetargetTargetSpec.DefaultRootBone"/>). An existing
    /// AnimationList's own non-empty value always wins.
    /// </summary>
    public string DefaultRootBone { get; init; } = "";

    /// <summary>
    /// When true, every CopyPinky ring→pinky constraint in the vmdl (the citizen base
    /// model's transitional <c>AnimConstraintOrient</c> folder, or any constraint whose
    /// driven bone is a <c>finger_pinky_*</c>) gets its weights set to 0, so the exported
    /// pinky channels are no longer overridden at runtime. Idempotent; everything else in
    /// the document is untouched.
    /// </summary>
    public bool NeutralizePinkyConstraints { get; init; }

    /// <summary>
    /// Detected locomotion families to splice as Folder + 2DBlend groups (see
    /// <see cref="LocomotionSetDetector"/>). Each set's member entries are grouped under a
    /// Folder named <see cref="LocomotionSetSpec.FolderName"/> instead of being spliced at
    /// the AnimationList top level. Pipeline-owned locomotion folders are rebuilt AS A UNIT:
    /// a previous run's same-named folder is replaced wholesale when every AnimFile inside
    /// it is pipeline-owned per <see cref="DmxFolderRelative"/> — even when the new batch is
    /// a SHRUNKEN family (e.g. 8-way re-run as 4-way), whose stale members are simply
    /// dropped with the old folder. A hand-edited or foreign folder is never destroyed (it
    /// throws <see cref="VmdlAugmentException"/> instead). Null/empty = no grouping.
    /// </summary>
    public IReadOnlyList<LocomotionSetSpec>? LocomotionSets { get; init; }

    /// <summary>
    /// Assets-relative folder this batch's DMX files live in (back- or forward slashes,
    /// trailing slash ignored). Decides which existing nodes are PIPELINE-OWNED — an
    /// AnimFile whose <c>source_filename</c> sits under this folder — mirroring the
    /// name-seeding ownership rules in <c>Retargeter.ConvertBatch</c>, so a folder the
    /// batch's collision seeding treated as replaceable is also replaceable here. Empty
    /// (default) = only sources with no directory component count as ours.
    /// </summary>
    public string DmxFolderRelative { get; init; } = "";

    /// <summary>
    /// Animation source paths (<c>source_filename</c> values, assets-relative, either slash
    /// direction) the IO-owning caller knows are MISSING on disk. Existing AnimFile nodes
    /// referencing them are removed from the augmented output — a single unresolvable source
    /// fails the whole vmdl recompile (<c>Node 'X' resolve failure</c>), so a stale entry
    /// left over from an earlier batch (its DMX deleted or moved) would take every animation
    /// in the model down with it. Nodes whose name this batch (re)writes are exempt — their
    /// DMX is about to exist. 2DBlend nodes referencing a pruned sequence and Folder nodes
    /// emptied by the pruning are removed with it. Null/empty = keep everything (default;
    /// the augmenter itself never touches the filesystem).
    /// </summary>
    public IReadOnlyCollection<string>? MissingSourceFiles { get; init; }
}

/// <summary>
/// Non-destructively splices AnimFile nodes into an existing vmdl's AnimationList. The rest
/// of the document tree is preserved (semantically — the file is re-serialized through
/// <see cref="Kv3"/>). Re-running with the same entries replaces the previously spliced
/// nodes, making augmentation idempotent.
/// </summary>
public static class VmdlAugmenter
{
    /// <summary>
    /// Returns <paramref name="vmdlText"/> with one AnimFile per entry inserted into the
    /// RootNode's AnimationList (created and appended when absent). Entries whose name
    /// matches an existing AnimFile replace it in place; a name match against any other
    /// node class throws <see cref="VmdlAugmentException"/> before anything is modified.
    /// </summary>
    /// <param name="vmdlText">The current vmdl file content.</param>
    /// <param name="anims">Animations to insert.</param>
    /// <param name="backupOfOriginal">Receives <paramref name="vmdlText"/> verbatim so
    /// callers can write a backup before overwriting the file.</param>
    /// <param name="options">Optional behavior knobs; null = defaults.</param>
    /// <param name="removedSequences">Optional sink receiving the names of stale nodes
    /// removed because their animation source is gone
    /// (<see cref="AugmentOptions.MissingSourceFiles"/>).</param>
    /// <exception cref="FormatException">Thrown when the text is not parseable KV3 or has no
    /// rootNode object.</exception>
    /// <exception cref="VmdlAugmentException">Thrown on name collisions with non-AnimFile
    /// nodes.</exception>
    /// <summary>
    /// Ensures a PIPELINE-OWNED standalone vmdl embeds <paramref name="meshFilePath"/> as a
    /// RenderMeshList/RenderMeshFile node. Standalone outputs generated before mesh
    /// embedding existed carry <c>base_model_name = ""</c> and no mesh — and because the
    /// standalone output ACCUMULATES (each conversion augments the existing file in place),
    /// such a vmdl would stay an empty model (0 bones, 0 playable sequences) forever, no
    /// matter how many new conversions run against it. A RenderMeshList referencing a
    /// DIFFERENT mesh is replaced wholesale (the user switched target FBX; the file is
    /// pipeline-owned). Never use on a user's own model vmdl — augment mode passes real
    /// models through untouched. Returns the (possibly unchanged) vmdl text.
    /// </summary>
    public static string EnsureMeshFile(string vmdlText, string meshFilePath, float meshImportScale,
        IReadOnlyDictionary<string, string>? materialRemaps = null)
    {
        ArgumentNullException.ThrowIfNull(vmdlText);
        if (string.IsNullOrEmpty(meshFilePath))
            return vmdlText;

        var doc = Kv3.Parse(vmdlText);
        if (doc.Root is not KvObject root || root.GetOrNull("rootNode") is not KvObject rootNode)
            throw new FormatException("vmdl has no rootNode object.");

        if (rootNode.GetOrNull("children") is not KvArray children)
        {
            children = new KvArray();
            rootNode["children"] = children;
        }

        var changed = false;

        var meshList = children.Items.OfType<KvObject>()
            .FirstOrDefault(o => o.GetString("_class") == "RenderMeshList");
        var alreadyEmbedded = meshList?.GetOrNull("children") is KvArray existing
            && existing.Items.OfType<KvObject>().Any(o =>
                o.GetString("_class") == "RenderMeshFile"
                && string.Equals(o.GetString("filename"), meshFilePath, StringComparison.OrdinalIgnoreCase));
        if (!alreadyEmbedded)
        {
            var replacement = VmdlWriter.BuildRenderMeshListNode(meshFilePath, meshImportScale);
            if (meshList is null)
                children.Items.Insert(0, replacement);
            else
                meshList["children"] = replacement.GetOrNull("children")!;
            changed = true;
        }

        // Material remaps ride along the same healing pass: a pre-remap output would keep
        // logging 'Missing vmat' / rendering placeholders forever. MERGED into an existing
        // DefaultMaterialGroup: entries for materials it does not know yet are appended
        // (an output vmdl written before a texture-matching improvement otherwise keeps
        // the stale table forever - observed as textured preview but untextured ModelDoc);
        // existing entries are left alone (they may carry user edits).
        if (materialRemaps is { Count: > 0 })
        {
            var materialList = children.Items.OfType<KvObject>()
                .FirstOrDefault(o => o.GetString("_class") == "MaterialGroupList");
            if (materialList is null)
            {
                children.Items.Add(VmdlWriter.BuildMaterialGroupListNode(materialRemaps));
                changed = true;
            }
            else if (materialList.GetOrNull("children") is KvArray groups
                && groups.Items.OfType<KvObject>()
                    .FirstOrDefault(o => o.GetString("_class") == "DefaultMaterialGroup") is { } group
                && group.GetOrNull("remaps") is KvArray remapArray)
            {
                var known = new HashSet<string>(
                    remapArray.Items.OfType<KvObject>()
                        .Select(o => o.GetString("from") ?? "")
                        .Where(f => f.Length > 0),
                    StringComparer.OrdinalIgnoreCase);
                foreach (var (from, to) in materialRemaps.OrderBy(kv => kv.Key, StringComparer.Ordinal))
                {
                    if (known.Contains(from))
                        continue;
                    remapArray.Items.Add(new KvObject
                    {
                        ["from"] = new KvString(from),
                        ["to"] = new KvString(to),
                    });
                    changed = true;
                }
            }
        }

        return changed ? Kv3.Serialize(doc) : vmdlText;
    }

    public static string Augment(string vmdlText, IEnumerable<AnimEntry> anims,
        out string backupOfOriginal, AugmentOptions? options = null,
        ICollection<string>? removedSequences = null)
    {
        ArgumentNullException.ThrowIfNull(vmdlText);
        ArgumentNullException.ThrowIfNull(anims);
        backupOfOriginal = vmdlText;
        options ??= new AugmentOptions();

        var entries = anims.ToList();
        var doc = Kv3.Parse(vmdlText);
        if (doc.Root is not KvObject root || root.GetOrNull("rootNode") is not KvObject rootNode)
            throw new FormatException("vmdl has no rootNode object.");

        if (rootNode.GetOrNull("children") is not KvArray children)
        {
            children = new KvArray();
            rootNode["children"] = children;
        }

        var animList = children.Items.OfType<KvObject>()
            .FirstOrDefault(o => o.GetString("_class") == "AnimationList");
        if (animList is null)
        {
            animList = new KvObject
            {
                ["_class"] = new KvString("AnimationList"),
                ["children"] = new KvArray(),
                ["default_root_bone_name"] = new KvString(options.DefaultRootBone),
            };
            children.Items.Add(animList);
        }

        if (animList.GetOrNull("children") is not KvArray listChildren)
        {
            listChildren = new KvArray();
            animList["children"] = listChildren;
        }

        var motionRootBone = animList.GetString("default_root_bone_name") ?? "";
        if (motionRootBone.Length == 0)
            motionRootBone = options.DefaultRootBone;

        var sets = options.LocomotionSets ?? Array.Empty<LocomotionSetSpec>();
        var groupedNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var set in sets)
        {
            foreach (var member in set.MemberNames)
                groupedNames.Add(member);
        }
        var dmxFolder = options.DmxFolderRelative.Replace('\\', '/').TrimEnd('/');

        // Validate all entries and set names first so a collision throws before any mutation.
        var collisions = new List<string>();
        foreach (var entry in entries)
        {
            var existing = FindByName(listChildren, entry.Name);
            if (existing is not null && existing.GetString("_class") != "AnimFile")
            {
                collisions.Add(
                    $"'{entry.Name}' already exists as {existing.GetString("_class") ?? "<unknown class>"}");
            }
        }
        foreach (var set in sets)
        {
            if (FindByName(listChildren, set.FolderName) is { } folderNode
                && !IsReplaceableLocomotionFolder(folderNode, dmxFolder))
            {
                collisions.Add(
                    $"'{set.FolderName}' already exists as "
                    + $"{folderNode.GetString("_class") ?? "<unknown class>"} whose content was "
                    + "not produced by this pipeline");
            }
            if (FindByName(listChildren, set.BlendName) is { } blendNode
                && blendNode.GetString("_class") != "2DBlend")
            {
                collisions.Add(
                    $"'{set.BlendName}' already exists as {blendNode.GetString("_class") ?? "<unknown class>"}");
            }
        }
        if (collisions.Count > 0)
            throw new VmdlAugmentException(collisions);

        // Loose entries (not grouped into a locomotion folder): replace in place wherever
        // they live — top level or inside a previously spliced folder — or append.
        foreach (var entry in entries)
        {
            if (groupedNames.Contains(entry.Name))
                continue;
            var node = VmdlWriter.BuildAnimFileNode(entry, motionRootBone);
            var (parent, index) = LocateByName(listChildren, entry.Name);
            if (parent is not null)
                parent.Items[index] = node; // idempotent re-run: replace same-named AnimFile
            else
                listChildren.Items.Add(node);
        }

        // Locomotion sets: rebuild each family's folder (replacing a previous run's folder
        // in place) and remove superseded loose copies of the members and blend node.
        foreach (var set in sets)
        {
            var insertAt = RemoveFolder(listChildren, set.FolderName);
            foreach (var member in set.MemberNames)
                RemoveEverywhere(listChildren, member, "AnimFile");
            RemoveEverywhere(listChildren, set.BlendName, "2DBlend");

            var folder = VmdlWriter.BuildLocomotionFolderNode(set, entries, motionRootBone);
            if (insertAt >= 0 && insertAt <= listChildren.Items.Count)
                listChildren.Items.Insert(insertAt, folder);
            else
                listChildren.Items.Add(folder);
        }

        // Stale entries whose animation source is gone from disk: prune them (plus blends
        // referencing them and folders they empty) — the compiler otherwise fails the WHOLE
        // vmdl on the first "Node 'X' resolve failure", newly added sequences included.
        if (options.MissingSourceFiles is { Count: > 0 })
        {
            var missing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            foreach (var path in options.MissingSourceFiles)
            {
                if (!string.IsNullOrWhiteSpace(path))
                    missing.Add(path.Replace('\\', '/'));
            }
            var batchNames = new HashSet<string>(StringComparer.Ordinal);
            foreach (var entry in entries)
                batchNames.Add(entry.Name);

            var pruned = new List<string>();
            var touched = new HashSet<KvArray>();
            PruneMissingSourceAnimFiles(listChildren, missing, batchNames, pruned, touched);
            if (pruned.Count > 0)
            {
                var prunedSet = new HashSet<string>(pruned, StringComparer.Ordinal);
                PruneBlendsReferencing(listChildren, prunedSet, pruned, touched);
                PruneEmptiedFolders(listChildren, pruned, touched);
                if (removedSequences is not null)
                {
                    foreach (var name in pruned)
                        removedSequences.Add(name);
                }
            }
        }

        if (options.NeutralizePinkyConstraints)
            NeutralizePinky(rootNode);

        return Kv3.Serialize(doc);
    }

    /// <summary>
    /// Every distinct AnimFile <c>source_filename</c> in the vmdl's own AnimationList
    /// (recursively through Folder nodes; prefab-provided entries are not part of this
    /// document and are not reported). IO-owning callers probe these against their content
    /// roots to build <see cref="AugmentOptions.MissingSourceFiles"/> /
    /// <see cref="BatchOptions.MissingAnimSources"/>. Unparseable text yields an empty list —
    /// the augmentation itself surfaces the parse error.
    /// </summary>
    public static IReadOnlyList<string> CollectAnimSourcePaths(string vmdlText)
    {
        ArgumentNullException.ThrowIfNull(vmdlText);

        Kv3Document doc;
        try
        {
            doc = Kv3.Parse(vmdlText);
        }
        catch (FormatException)
        {
            return Array.Empty<string>();
        }

        if (doc.Root is not KvObject root || root.GetOrNull("rootNode") is not KvObject rootNode
            || rootNode.GetOrNull("children") is not KvArray children)
            return Array.Empty<string>();
        var animList = children.Items.OfType<KvObject>()
            .FirstOrDefault(o => o.GetString("_class") == "AnimationList");
        if (animList?.GetOrNull("children") is not KvArray items)
            return Array.Empty<string>();

        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var paths = new List<string>();
        Collect(items);
        return paths;

        void Collect(KvArray array)
        {
            foreach (var node in array.Items.OfType<KvObject>())
            {
                if (node.GetString("_class") == "AnimFile")
                {
                    var source = node.GetString("source_filename") ?? "";
                    if (source.Length > 0 && seen.Add(source.Replace('\\', '/')))
                        paths.Add(source);
                }
                if (node.GetOrNull("children") is KvArray nested)
                    Collect(nested);
            }
        }
    }

    // ------------------------------------------------------- missing-source pruning

    /// <summary>Removes every AnimFile (recursively through Folders) whose
    /// <c>source_filename</c> is in <paramref name="missing"/>, except nodes named by this
    /// batch (their DMX is about to be written). Removed names collect in
    /// <paramref name="pruned"/>; arrays a node was removed from in <paramref name="touched"/>.</summary>
    private static void PruneMissingSourceAnimFiles(
        KvArray items, HashSet<string> missing, HashSet<string> batchNames,
        List<string> pruned, HashSet<KvArray> touched)
    {
        for (var i = items.Items.Count - 1; i >= 0; i--)
        {
            if (items.Items[i] is not KvObject node)
                continue;
            if (node.GetString("_class") == "AnimFile")
            {
                var name = node.GetString("name") ?? "";
                var source = (node.GetString("source_filename") ?? "").Replace('\\', '/');
                if (source.Length > 0 && missing.Contains(source) && !batchNames.Contains(name))
                {
                    items.Items.RemoveAt(i);
                    touched.Add(items);
                    if (name.Length > 0)
                        pruned.Add(name);
                }
                continue;
            }
            if (node.GetOrNull("children") is KvArray nested)
                PruneMissingSourceAnimFiles(nested, missing, batchNames, pruned, touched);
        }
    }

    /// <summary>Removes 2DBlend nodes whose <c>blend_anim_list</c> references a sequence
    /// pruned by the missing-source pass — the blend cannot resolve its member either. Blends
    /// referencing only intact sequences (including prefab-provided ones) are untouched.</summary>
    private static void PruneBlendsReferencing(
        KvArray items, HashSet<string> prunedSet, List<string> pruned, HashSet<KvArray> touched)
    {
        for (var i = items.Items.Count - 1; i >= 0; i--)
        {
            if (items.Items[i] is not KvObject node)
                continue;
            if (node.GetString("_class") == "2DBlend" && BlendReferencesAny(node, prunedSet))
            {
                items.Items.RemoveAt(i);
                touched.Add(items);
                var name = node.GetString("name") ?? "";
                if (name.Length > 0)
                    pruned.Add(name);
                continue;
            }
            if (node.GetOrNull("children") is KvArray nested)
                PruneBlendsReferencing(nested, prunedSet, pruned, touched);
        }
    }

    /// <summary>Whether any non-empty cell of the blend's grid names a pruned sequence.</summary>
    private static bool BlendReferencesAny(KvObject blendNode, HashSet<string> prunedSet)
    {
        if (blendNode.GetOrNull("blend_anim_list") is not KvArray grid)
            return false;
        foreach (var row in grid.Items.OfType<KvArray>())
        {
            foreach (var cell in row.Items.OfType<KvString>())
            {
                if (cell.Value.Length > 0 && prunedSet.Contains(cell.Value))
                    return true;
            }
        }
        return false;
    }

    /// <summary>Removes Folder nodes the pruning EMPTIED (children array now empty AND a
    /// node was actually removed from it — pre-existing empty folders are user data and
    /// survive). Removing an inner folder marks the outer array touched, so folders that
    /// emptied transitively fall too.</summary>
    private static void PruneEmptiedFolders(KvArray items, List<string> pruned, HashSet<KvArray> touched)
    {
        for (var i = items.Items.Count - 1; i >= 0; i--)
        {
            if (items.Items[i] is not KvObject node || node.GetString("_class") != "Folder")
                continue;
            if (node.GetOrNull("children") is not KvArray nested)
                continue;
            PruneEmptiedFolders(nested, pruned, touched);
            if (nested.Items.Count > 0 || !touched.Contains(nested))
                continue;
            items.Items.RemoveAt(i);
            touched.Add(items);
            var name = node.GetString("name") ?? "";
            if (name.Length > 0)
                pruned.Add(name);
        }
    }

    // ---------------------------------------------------------------- locomotion folders

    /// <summary>
    /// Whether an existing node may be replaced by a locomotion folder splice: it must be a
    /// Folder containing at least one AnimFile, and every AnimFile anywhere inside it must
    /// be pipeline-owned (<c>source_filename</c> under <paramref name="dmxFolder"/>) — i.e.
    /// the folder holds nothing of the user's own. Ownership deliberately ignores
    /// current-batch membership: a previous run's 8-way family re-run as a 4-way batch is
    /// still OUR folder, rebuilt as a unit with its stale members dropped. This mirrors
    /// <c>Retargeter</c>'s name-seeding ownership rules so a folder whose name the seeding
    /// released to this batch is never refused here.
    /// </summary>
    private static bool IsReplaceableLocomotionFolder(KvObject node, string dmxFolder)
    {
        if (node.GetString("_class") != "Folder")
            return false;
        var sawAnimFile = false;
        return AllAnimFilesOurs(node) && sawAnimFile;

        bool AllAnimFilesOurs(KvObject current)
        {
            if (current.GetString("_class") == "AnimFile")
            {
                sawAnimFile = true;
                if (!IsPipelineAnimFile(current, dmxFolder))
                    return false;
            }
            if (current.GetOrNull("children") is KvArray children)
            {
                foreach (var child in children.Items.OfType<KvObject>())
                {
                    if (!AllAnimFilesOurs(child))
                        return false;
                }
            }
            return true;
        }
    }

    /// <summary>Whether an AnimFile was written by this pipeline: its
    /// <c>source_filename</c> sits inside the batch's DMX folder (empty folder = a source
    /// with no directory component). Same rule <c>Retargeter</c> seeds collision names with.</summary>
    private static bool IsPipelineAnimFile(KvObject node, string dmxFolder)
    {
        var source = (node.GetString("source_filename") ?? "").Replace('\\', '/');
        return dmxFolder.Length == 0
            ? !source.Contains('/')
            : source.StartsWith(dmxFolder + "/", StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>Removes the Folder named <paramref name="name"/>; returns its top-level
    /// index (the position the rebuilt folder is re-inserted at) or −1 when it was absent
    /// or nested.</summary>
    private static int RemoveFolder(KvArray listChildren, string name)
    {
        var (parent, index) = LocateByName(listChildren, name);
        if (parent is null || ((KvObject)parent.Items[index]).GetString("_class") != "Folder")
            return -1;
        parent.Items.RemoveAt(index);
        return ReferenceEquals(parent, listChildren) ? index : -1;
    }

    /// <summary>Removes every node of class <paramref name="className"/> named
    /// <paramref name="name"/>, anywhere in the AnimationList tree (top level or inside
    /// Folder nodes) — superseded copies from previous runs with different grouping.</summary>
    private static void RemoveEverywhere(KvArray items, string name, string className)
    {
        for (var i = items.Items.Count - 1; i >= 0; i--)
        {
            if (items.Items[i] is not KvObject node)
                continue;
            if (node.GetString("_class") == className
                && string.Equals(node.GetString("name"), name, StringComparison.Ordinal))
            {
                items.Items.RemoveAt(i);
                continue;
            }
            if (node.GetString("_class") == "Folder" && node.GetOrNull("children") is KvArray nested)
                RemoveEverywhere(nested, name, className);
        }
    }

    // ---------------------------------------------------------------- CopyPinky neutralization

    /// <summary>
    /// Zeroes the weights of every pinky-driving constraint reachable from the root node:
    /// the citizen vmdl's CopyPinky Folder (a folder of AnimConstraintOrient nodes copying
    /// finger_ring_* onto finger_pinky_*) and any other AnimConstraint* node whose
    /// AnimConstraintSlave drives a <c>finger_pinky_*</c> bone. Other weights in the
    /// document (e.g. WeightList entries) are never touched.
    /// </summary>
    private static void NeutralizePinky(KvObject node)
    {
        var cls = node.GetString("_class") ?? "";

        var isCopyPinkyFolder = cls == "Folder"
            && string.Equals(node.GetString("name"), "CopyPinky", StringComparison.Ordinal);
        var isPinkyConstraint = cls.StartsWith("AnimConstraint", StringComparison.Ordinal)
            && cls != "AnimConstraintList"
            && DrivesPinkyBone(node);

        if (isCopyPinkyFolder || isPinkyConstraint)
        {
            ZeroWeights(node);
            return; // whole subtree handled
        }

        if (node.GetOrNull("children") is KvArray children)
        {
            foreach (var child in children.Items.OfType<KvObject>())
                NeutralizePinky(child);
        }
    }

    /// <summary>Whether any descendant AnimConstraintSlave drives a finger_pinky_* bone.</summary>
    private static bool DrivesPinkyBone(KvObject node)
    {
        if (node.GetString("_class") == "AnimConstraintSlave"
            && (node.GetString("parent_bone") ?? "")
                .StartsWith("finger_pinky_", StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        if (node.GetOrNull("children") is KvArray children)
        {
            foreach (var child in children.Items.OfType<KvObject>())
            {
                if (DrivesPinkyBone(child))
                    return true;
            }
        }
        return false;
    }

    /// <summary>Sets every <c>weight</c> attribute in the subtree to 0.0 (idempotent).</summary>
    private static void ZeroWeights(KvObject node)
    {
        if (node.GetOrNull("weight") is not null)
            node["weight"] = new KvDouble(0.0);

        if (node.GetOrNull("children") is KvArray children)
        {
            foreach (var child in children.Items.OfType<KvObject>())
                ZeroWeights(child);
        }
    }

    private static KvObject? FindByName(KvArray items, string name)
    {
        var (parent, index) = LocateByName(items, name);
        return parent is not null ? (KvObject)parent.Items[index] : null;
    }

    /// <summary>Locates a node by name at the AnimationList top level or inside Folder
    /// nodes (recursively — previously spliced locomotion folders contain our AnimFiles):
    /// the owning array + index, or (null, −1) when absent.</summary>
    private static (KvArray? Parent, int Index) LocateByName(KvArray items, string name)
    {
        for (var i = 0; i < items.Items.Count; i++)
        {
            if (items.Items[i] is not KvObject o)
                continue;
            if (string.Equals(o.GetString("name"), name, StringComparison.Ordinal))
                return (items, i);
            if (o.GetString("_class") == "Folder" && o.GetOrNull("children") is KvArray nested)
            {
                var found = LocateByName(nested, name);
                if (found.Parent is not null)
                    return found;
            }
        }
        return (null, -1);
    }
}