Code/HumanoidRetargeter/Retargeter.cs

Top-level pipeline facade for humanoid retargeting. Imports source animation bytes, detects or applies mappings, runs solvers and cleanup (foot-plant, IK bake, root-motion, orphan bone anchoring), produces DMX animation blobs and assembles vmdl entries; supports batches, clip definitions, mirrored/additive variants and vmdl augmentation.

File Access
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Cleanup;
using HumanoidRetargeter.Formats;
using HumanoidRetargeter.Formats.Bvh;
using HumanoidRetargeter.Formats.Dmx;
using HumanoidRetargeter.Formats.Fbx;
using HumanoidRetargeter.Formats.Gltf;
using HumanoidRetargeter.Formats.Renderware;
using HumanoidRetargeter.Mapping;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Solve;
using HumanoidRetargeter.Target;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;

namespace HumanoidRetargeter;

using Vector3 = System.Numerics.Vector3; // s&box compat: shadow engine's global-namespace Vector3 (see Code/HumanoidRetargeter/Assembly.cs)

/// <summary>
/// Engine-agnostic pipeline facade (design §5): bytes in → DMX + vmdl text out. Composes
/// import → mapping → solve → cleanup → IK baking → DMX → vmdl assembly. Batch is
/// first-class: N files × all takes per file → ONE combined vmdl, with per-request profile
/// detection (a batch may mix Mixamo + ActorCore + BVH sources) and per-clip failure
/// isolation. No file IO anywhere in this type — callers pass bytes/strings and write the
/// returned strings.
/// </summary>
/// <remarks>
/// Pipeline per clip:
/// <list type="number">
/// <item><b>Import</b> — format by file extension (<c>.fbx</c>/<c>.bvh</c>/<c>.glb</c>/
/// <c>.gltf</c>/<c>.vrm</c>/<c>.anm</c>/<c>.an5</c>), content sniff as fallback →
/// <see cref="SourceScene"/> (cm, native axes). RenderWare animations additionally take the
/// companion model's .dff bytes via <see cref="RetargetRequest.SkeletonData"/>.</item>
/// <item><b>Mapping</b> — per request: <see cref="RetargetRequest.MappingOverride"/> wins;
/// else <see cref="ProfileDetector.Detect"/> over the preset library (user presets are
/// loaded Editor-side and arrive as overrides); else <see cref="AutoMapper.Map"/> as best
/// effort with <see cref="MappingReportInfo.NeedsUserDecision"/> set when its confidence is
/// below <see cref="ProfileDetector.DetectionThreshold"/>.</item>
/// <item><b>Solve</b> — <see cref="GeometricSolver"/> per take (clip name = take name, or
/// <see cref="RetargetRequest.ClipNameOverride"/> + index).</item>
/// <item><b>Cleanup</b> (target space) — <see cref="FootPlant"/> when enabled (chains from
/// the target rig's UpperLeg/LowerLeg/Foot/Toe roles, up axis from the TARGET character
/// frame, fps from the clip); optional arm <see cref="EffectorIk"/> (off by default — the
/// solver already matches anatomical directions); <see cref="RootMotion"/> per mode (see
/// below).</item>
/// <item><b>IK baking</b> — <see cref="IkBoneBaker.Bake"/> when the rig has
/// <see cref="BoneClass.IkBaked"/> bones.</item>
/// <item><b>DMX</b> — <see cref="DmxWriter"/> over the target skeleton.</item>
/// <item><b>Assembly</b> — <see cref="VmdlWriter.GenerateStandalone"/> over all successful
/// clips, plus <see cref="VmdlAugmenter.Augment"/> when
/// <see cref="BatchOptions.AugmentVmdlText"/> is provided; clip-name collisions are
/// auto-suffixed (<c>_2</c>, <c>_3</c>, …) across the batch.</item>
/// </list>
/// <para><b>Root-motion ↔ target mapping.</b> <see cref="RootMotionMode.Extract"/> bakes the
/// ground-projected hips path onto the target's dedicated root bone — defined as a parentless
/// <see cref="BoneClass.Animated"/> bone that is an ancestor of (and distinct from) the Hips
/// bone. The s&amp;box rig has NO such bone (pelvis is itself parentless; root_IK is IkBaked),
/// so there Extract leaves the frames untouched, adds a report note, and instead sets
/// <see cref="AnimEntry.ExtractMotion"/> on the clip's vmdl entry: Source 2 extracts the
/// ground-plane translation from <see cref="RetargetTargetSpec.DefaultRootBone"/> at compile
/// time, which is the engine-native equivalent. The ExtractMotion flag is set for every
/// Extract request either way. <see cref="RootMotionMode.InPlace"/> always operates on the
/// hips channels directly and needs no dedicated root.</para>
/// </remarks>
public static class Retargeter
{
    /// <summary>Converts one source file — all takes, or only
    /// <see cref="RetargetRequest.TakeIndex"/> when set. Equivalent to a one-request batch.</summary>
    public static RetargetResult Convert(RetargetRequest request, RetargetTargetSpec target)
    {
        ArgumentNullException.ThrowIfNull(request);
        var batch = ConvertBatch(new[] { request }, target);
        return new RetargetResult
        {
            Clips = batch.Clips,
            StandaloneVmdl = batch.StandaloneVmdl,
            Errors = batch.Errors,
        };
    }

    /// <summary>
    /// Converts a batch of source files against one target. Per-request profile detection,
    /// per-clip failure isolation (a bad file yields a failed <see cref="ClipResult"/> and
    /// the batch continues), one standalone vmdl with every successful clip, optional
    /// augmentation of an existing vmdl.
    /// </summary>
    public static RetargetBatchResult ConvertBatch(
        IReadOnlyList<RetargetRequest> requests, RetargetTargetSpec target, BatchOptions? options = null)
    {
        ArgumentNullException.ThrowIfNull(requests);
        ArgumentNullException.ThrowIfNull(target);
        options ??= new BatchOptions();

        var context = new TargetContext(target.Rig);
        var result = new RetargetBatchResult();
        var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var entries = new List<AnimEntry>();

        // Augment mode: names already taken in the existing vmdl's AnimationList must not be
        // silently repointed — seed the collision set with every existing node name EXCEPT
        // our own AnimFiles (source_filename under DmxFolderRelative), which are replaceable
        // so re-running the same batch stays idempotent.
        if (options.AugmentVmdlText is not null)
            SeedUsedNamesFromExistingVmdl(options.AugmentVmdlText, options.DmxFolderRelative, usedNames);

        var mappedPinky = false;
        foreach (var request in requests)
        {
            if (request is null)
                continue;
            mappedPinky |= ProcessRequest(
                request, target, context, options, usedNames, result.Clips, entries);
        }

        foreach (var clip in result.Clips)
        {
            if (!clip.Success)
                result.Errors.Add($"{clip.SourceFileName}: {clip.Error}");
        }

        // Locomotion families are detected on the FINAL (collision-suffixed) clip names so
        // the blend grids reference exactly what the vmdl registers; folder/blend names are
        // made unique against the same name set the clips used.
        IReadOnlyList<LocomotionSetSpec>? locomotionSets = null;
        if (options.DetectLocomotionSets)
        {
            var (sets, reports) = LocomotionSetDetector.Detect(
                entries, usedNames, options.AutoSuffixCollisions);
            result.LocomotionSets.AddRange(reports);
            if (sets.Count > 0)
                locomotionSets = sets;
        }

        // The target's OWN embedded animations ride along every generated vmdl (an FBX
        // picked as target keeps its animation next to the retargeted ones). Skipped when
        // a same-named entry is already registered (idempotent re-runs / augment targets
        // that carry them from an earlier conversion); the retargeted clips claimed their
        // names first, so an embedded take never displaces a converted clip.
        if (target.ExtraAnimFiles is { Count: > 0 })
        {
            foreach (var extra in target.ExtraAnimFiles)
            {
                if (usedNames.Add(extra.Name))
                    entries.Add(extra);
            }
        }

        result.StandaloneVmdl = VmdlWriter.GenerateStandalone(
            target.BaseModelPath, entries, target.VmdlScale, target.DefaultRootBone,
            locomotionSets, target.MeshFilePath, target.MeshImportScale, target.MaterialRemaps);

        if (options.AugmentVmdlText is not null)
        {
            try
            {
                var removedSequences = new List<string>();
                result.AugmentedVmdl = VmdlAugmenter.Augment(
                    options.AugmentVmdlText, entries, out _,
                    new AugmentOptions
                    {
                        DefaultRootBone = target.DefaultRootBone,
                        // The citizen base model copies ring→pinky via its CopyPinky
                        // constraints; when this batch actually drives the pinky, the
                        // augmented vmdl must neutralize them or the exported pinky
                        // channels get overridden at runtime.
                        NeutralizePinkyConstraints = mappedPinky,
                        LocomotionSets = locomotionSets,
                        // Same ownership rule the name seeding above used: pipeline-owned
                        // locomotion folders (every AnimFile sourced under our DMX folder)
                        // are replaceable wholesale on re-runs.
                        DmxFolderRelative = options.DmxFolderRelative,
                        // Stale entries whose DMX the caller found missing on disk: keep
                        // them and the WHOLE vmdl stops compiling ("Node 'X' resolve
                        // failure"), new sequences included.
                        MissingSourceFiles = options.MissingAnimSources,
                    },
                    removedSequences);
                foreach (var name in removedSequences)
                {
                    result.Warnings.Add(
                        $"removed stale sequence '{name}' from the augmented vmdl: its "
                        + "animation source file no longer exists on disk (a missing source "
                        + "fails the whole model recompile)");
                }
            }
            catch (Exception e)
            {
                result.Errors.Add($"vmdl augmentation failed: {e.Message}");
            }
        }

        return result;
    }

    /// <summary>Collects names already present in the existing vmdl's AnimationList that the
    /// batch must not reuse (everything except our own replaceable AnimFiles).</summary>
    private static void SeedUsedNamesFromExistingVmdl(
        string vmdlText, string dmxFolderRelative, HashSet<string> usedNames)
    {
        Kv3Document doc;
        try
        {
            doc = Kv3.Parse(vmdlText);
        }
        catch (FormatException)
        {
            return; // the augmentation step itself surfaces the parse error
        }

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

        var folder = dmxFolderRelative.Replace('\\', '/').TrimEnd('/');
        SeedNames(items, folder, usedNames);
    }

    /// <summary>
    /// Recursive seeding step: every named AnimationList node reserves its name EXCEPT our
    /// own replaceable output — AnimFiles whose source lives in the batch's DMX folder, and
    /// locomotion Folders consisting entirely of such AnimFiles (a re-run replaces the whole
    /// folder, so neither its name nor its content may block the new batch's names). Foreign
    /// folders are seeded by name and their content seeded recursively.
    /// </summary>
    private static void SeedNames(KvArray items, string dmxFolder, HashSet<string> usedNames)
    {
        foreach (var node in items.Items.OfType<KvObject>())
        {
            var name = node.GetString("name");
            var cls = node.GetString("_class");

            if (cls == "AnimFile")
            {
                if (!string.IsNullOrEmpty(name) && !IsOurAnimFile(node, dmxFolder))
                    usedNames.Add(name);
                continue;
            }

            if (cls == "Folder")
            {
                if (IsOurLocomotionFolder(node, dmxFolder))
                    continue; // replaceable wholesale — nothing inside reserves a name
                if (!string.IsNullOrEmpty(name))
                    usedNames.Add(name);
                if (node.GetOrNull("children") is KvArray nested)
                    SeedNames(nested, dmxFolder, usedNames);
                continue;
            }

            if (!string.IsNullOrEmpty(name))
                usedNames.Add(name);
        }
    }

    /// <summary>Whether an AnimFile was written by this pipeline: its source_filename sits
    /// inside the batch's DMX folder.</summary>
    private static bool IsOurAnimFile(KvObject node, string dmxFolder)
    {
        var source = (node.GetString("source_filename") ?? "").Replace('\\', '/');
        return dmxFolder.Length == 0
            ? !source.Contains('/')
            : source.StartsWith(dmxFolder + "/", StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>Whether a Folder is a locomotion group this pipeline spliced: it contains at
    /// least one AnimFile and every AnimFile inside it (recursively) is ours.</summary>
    private static bool IsOurLocomotionFolder(KvObject folderNode, string dmxFolder)
    {
        var sawAnimFile = false;
        return Walk(folderNode) && sawAnimFile;

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

    /// <summary>
    /// Detection-only entry point for UI listings: imports the file and runs the same
    /// preset-then-auto mapping as conversion, without solving anything. Also reports the
    /// file's take metadata (clip names, in take-index order) so listings can expand a
    /// multi-take file into one entry per take. Throws <see cref="FormatException"/> when
    /// the file is unreadable.
    /// </summary>
    public static InspectResult Inspect(byte[] sourceData, string fileName, byte[]? skeletonData = null)
    {
        ArgumentNullException.ThrowIfNull(sourceData);
        ArgumentNullException.ThrowIfNull(fileName);
        var scene = ImportSource(sourceData, fileName, skeletonData: skeletonData);
        var takeNames = new List<string>(scene.Clips.Count);
        foreach (var clip in scene.Clips)
            takeNames.Add(clip.Name);
        return new InspectResult
        {
            Mapping = ResolveMapping(scene.Skeleton, authoredMapping: scene.AuthoredMapping).Report,
            TakeNames = takeNames,
        };
    }

    // ================================================================ per-request pipeline

    /// <summary>Runs one request end to end. Returns true when at least one clip converted
    /// successfully with a mapping that drives a pinky role (CopyPinky handling).</summary>
    private static bool ProcessRequest(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        BatchOptions options, HashSet<string> usedNames,
        List<ClipResult> clips, List<AnimEntry> entries)
    {
        var sourceId = request.SourceId ?? request.SourceFileName;
        SourceScene scene;
        MappingReportInfo report;
        MappingResult map;
        try
        {
            scene = ImportSource(
                request.SourceData, request.SourceFileName, request.SampleFps, request.SkeletonData);
            (map, report) = ResolveMapping(
                scene.Skeleton, request.MappingOverride, authoredMapping: scene.AuthoredMapping);
            // Import diagnostics (mid-pose exports, cross-stack static disagreements, ...)
            // ride the mapping report so UIs can surface them next to the conversion result.
            foreach (var note in scene.Notes)
                AddNote(report, note);
            scene = MaybeAdoptBindRest(scene, map, report);
        }
        catch (Exception e)
        {
            clips.Add(new ClipResult
            {
                ClipName = FileStem(request.SourceFileName),
                SourceFileName = request.SourceFileName,
                SourceId = sourceId,
                Success = false,
                Error = e.Message,
            });
            return false;
        }

        if (scene.Clips.Count == 0)
        {
            clips.Add(new ClipResult
            {
                ClipName = FileStem(request.SourceFileName),
                SourceFileName = request.SourceFileName,
                SourceId = sourceId,
                Mapping = report,
                Success = false,
                Error = "Source file contains no animation takes.",
            });
            return false;
        }

        var mapsPinky = MapsPinkyRole(map);
        if (mapsPinky && options.AugmentVmdlText is null
            && target.BaseModelPath == RetargetTargetSpec.SboxHumanMalePath)
        {
            // A standalone child vmdl cannot override the base model's AnimConstraintList:
            // the citizen's CopyPinky (ring → pinky orient copy) keeps driving the pinky at
            // runtime even though the DMX carries real pinky channels.
            AddNote(report,
                "Pinky channels are exported, but the base model's CopyPinky constraints "
                + "(ring → pinky copy) cannot be overridden from a standalone vmdl, so the "
                + "pinky will visibly mirror the ring finger's motion in engine — use "
                + "augment mode (add to the existing vmdl) to neutralize them.");
        }
        if (target.UpAxis == TargetUpAxis.ZUpEngine)
        {
            AddNote(report,
                "Target rig is engine-space (Z-up, inches): the DMX declares a Z-up axis "
                + "system so resourcecompiler performs no Y-up conversion (best-effort — "
                + "verify the compiled sequence on engine-space targets).");
        }

        // External clip definitions (Unity .fbx.meta clipAnimations): one output clip per
        // DEFINITION, sliced out of its take; TakeIndex addresses the definitions then
        // (see RetargetRequest.ClipDefinitions).
        if (request.ClipDefinitions is { Count: > 0 } clipDefs)
        {
            return ProcessClipDefinitions(
                request, target, context, options, usedNames, clips, entries,
                scene, map, report, sourceId, mapsPinky, clipDefs);
        }

        // TakeIndex narrows the conversion to a single take (UI per-take entries submit one
        // request per selected take); null keeps the historical convert-all-takes behavior.
        var takeStart = 0;
        var takeEnd = scene.Clips.Count;
        if (request.TakeIndex is { } takeIndex)
        {
            if (takeIndex < 0 || takeIndex >= scene.Clips.Count)
            {
                clips.Add(new ClipResult
                {
                    ClipName = FileStem(request.SourceFileName),
                    SourceFileName = request.SourceFileName,
                    SourceId = sourceId,
                    Mapping = report,
                    Success = false,
                    Error = $"Take index {takeIndex} is out of range: the source file "
                        + $"contains {scene.Clips.Count} take(s).",
                });
                return false;
            }
            takeStart = takeIndex;
            takeEnd = takeIndex + 1;
        }

        var anyPinkySuccess = false;
        for (var take = takeStart; take < takeEnd; take++)
        {
            var clipName = UniqueClipName(
                SanitizeClipName(RequestedClipName(request, scene, take)),
                usedNames, options.AutoSuffixCollisions);
            anyPinkySuccess |= ConvertOne(
                request, target, context, options, usedNames, clips, entries,
                scene, map, report, sourceId, mapsPinky, take, clipName);
        }

        return anyPinkySuccess;
    }

    /// <summary>
    /// The definitions variant of the take loop: each <see cref="ExternalClipDef"/> becomes
    /// its own clip result — the definition's take is located by
    /// <see cref="ExternalClipDef.TakeName"/> (falling back to the file's first take), sliced
    /// to the definition's native-frame range via <see cref="UnityMeta.Slice"/> and solved as
    /// a single-clip scene. Returns true when at least one clip converted successfully with a
    /// pinky-driving mapping.
    /// </summary>
    private static bool ProcessClipDefinitions(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        BatchOptions options, HashSet<string> usedNames,
        List<ClipResult> clips, List<AnimEntry> entries,
        SourceScene scene, MappingResult map, MappingReportInfo report,
        string sourceId, bool mapsPinky, IReadOnlyList<ExternalClipDef> clipDefs)
    {
        var defStart = 0;
        var defEnd = clipDefs.Count;
        if (request.TakeIndex is { } defIndex)
        {
            if (defIndex < 0 || defIndex >= clipDefs.Count)
            {
                clips.Add(new ClipResult
                {
                    ClipName = FileStem(request.SourceFileName),
                    SourceFileName = request.SourceFileName,
                    SourceId = sourceId,
                    Mapping = report,
                    Success = false,
                    Error = $"Clip-definition index {defIndex} is out of range: the request "
                        + $"carries {clipDefs.Count} clip definition(s).",
                });
                return false;
            }
            defStart = defIndex;
            defEnd = defIndex + 1;
        }

        var anyPinkySuccess = false;
        for (var d = defStart; d < defEnd; d++)
        {
            var def = clipDefs[d];
            var requestedName = !string.IsNullOrWhiteSpace(def.Name)
                ? def.Name
                : clipDefs.Count > 1 ? $"{FileStem(request.SourceFileName)}_{d + 1}" : FileStem(request.SourceFileName);
            var clipName = UniqueClipName(
                SanitizeClipName(requestedName), usedNames, options.AutoSuffixCollisions);

            var take = TakeForDefinition(scene, def);
            var sliced = UnityMeta.Slice(scene.Clips[take], def, clipName);

            // Same skeleton/axes, ONE clip: the regular pipeline then solves "take 0" of it.
            var defScene = new SourceScene(
                scene.Skeleton, new[] { sliced }, scene.UnitScaleCm,
                scene.UpAxis, scene.UpAxisSign,
                scene.FrontAxis, scene.FrontAxisSign,
                scene.CoordAxis, scene.CoordAxisSign,
                scene.OriginalUpAxis, scene.Notes)
            {
                AuthoredMapping = scene.AuthoredMapping,
                RestPlacementAuthored = scene.RestPlacementAuthored,
            };

            anyPinkySuccess |= ConvertOne(
                request, target, context, options, usedNames, clips, entries,
                defScene, map, report, sourceId, mapsPinky, take: 0, clipName);
        }

        return anyPinkySuccess;
    }

    /// <summary>The take a definition's frame range refers to: matched by take name when the
    /// definition records one (Unity's <c>takeName</c>, e.g. <c>root|Animation</c>), else —
    /// and when nothing matches — the file's first take.</summary>
    private static int TakeForDefinition(SourceScene scene, ExternalClipDef def)
    {
        if (!string.IsNullOrWhiteSpace(def.TakeName))
        {
            for (var i = 0; i < scene.Clips.Count; i++)
            {
                if (string.Equals(scene.Clips[i].Name, def.TakeName, StringComparison.Ordinal))
                    return i;
            }
        }
        return 0;
    }

    /// <summary>Solves ONE clip (take <paramref name="take"/> of <paramref name="scene"/>)
    /// end to end — solve + cleanup + DMX, plus the optional mirrored twin
    /// (<see cref="RetargetRequest.CreateMirroredVariant"/>) — appending success or failure
    /// <see cref="ClipResult"/>s (failures never abort the batch). Returns true on success
    /// with a pinky-driving mapping.</summary>
    private static bool ConvertOne(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        BatchOptions options, HashSet<string> usedNames,
        List<ClipResult> clips, List<AnimEntry> entries,
        SourceScene scene, MappingResult map, MappingReportInfo report,
        string sourceId, bool mapsPinky, int take, string clipName)
    {
        Clip clip;
        try
        {
            clip = SolveAndClean(request, target, context, scene, map, report, take, clipName);
            EmitClip(request, target, context, options, usedNames, clips, entries, report,
                sourceId, clipName, clip.Frames, clip.Fps, clip.Looping, isMirrored: false);
        }
        catch (Exception e)
        {
            clips.Add(new ClipResult
            {
                ClipName = clipName,
                SourceFileName = request.SourceFileName,
                SourceId = sourceId,
                Mapping = report,
                Success = false,
                Error = e.Message,
            });
            return false;
        }

        if (request.CreateMirroredVariant)
        {
            // The twin gets its own collision-suffixed name and its own failure isolation:
            // a mirror problem (asymmetric rig) fails ONLY the twin, never the primary clip.
            var mirroredName = UniqueClipName(
                SanitizeClipName(clipName + "_M"), usedNames, options.AutoSuffixCollisions);
            try
            {
                // SOURCE-SIDE MIRROR (southpaw G8 mirror fix, gate3_review.md 3.4 and the
                // G8 bisect evidence): the SOURCE clip is mirrored across the source
                // character's sagittal plane and the twin then runs through the COMPLETE
                // unmodified primary pipeline (solver, cleanups, ground alignment, IK
                // bake, orphan re-anchor). The emitted _M clip is a first-class primary
                // clip in every representational respect, indistinguishable from a clip
                // an animator authored mirrored. Target-space channel mirroring, however
                // geometrically exact, produced data the ENGINE's render-side sequence
                // evaluation mangles (measured: a mirrored pelvis channel alone renders
                // the whole model upside down while every CPU-side bone API reports a
                // perfect upright mirror); no data conjugation convention survived that
                // stage, so the mirror moved to the source side where no alien data
                // shape can exist.
                var mirroredScene = SourceMirror.MirrorTake(scene, map, take);
                var mirroredClip = SolveAndClean(
                    request, target, context, mirroredScene, map, report, take, mirroredName);
                EmitClip(request, target, context, options, usedNames, clips, entries, report,
                    sourceId, mirroredName, mirroredClip.Frames, mirroredClip.Fps,
                    clip.Looping, isMirrored: true);
            }
            catch (Exception e)
            {
                clips.Add(new ClipResult
                {
                    ClipName = mirroredName,
                    SourceFileName = request.SourceFileName,
                    SourceId = sourceId,
                    Mapping = report,
                    Success = false,
                    Error = $"mirrored variant: {e.Message}",
                });
            }
        }

        return mapsPinky;
    }

    /// <summary>Shared tail of clip production (primary and mirrored twin): optional footstep
    /// events, DMX serialization, the <see cref="ClipResult"/> and the vmdl
    /// <see cref="AnimEntry"/> — plus the additive (<c>_delta</c>) companion entry when
    /// <see cref="RetargetRequest.CreateAdditiveVariant"/> is on (a second AnimFile REUSING
    /// the clip's DMX with an AnimSubtract child, shipped-citizen shape; no separate
    /// <see cref="ClipResult"/> since no separate DMX exists).</summary>
    private static void EmitClip(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        BatchOptions options, HashSet<string> usedNames,
        List<ClipResult> clips, List<AnimEntry> entries,
        MappingReportInfo report, string sourceId,
        string clipName, List<XForm[]> frames, float fps, bool looping, bool isMirrored)
    {
        var events = GenerateFootsteps(request, target, context, report, frames, fps);
        var dmxFileName = SanitizeFileName(clipName) + ".dmx";
        // Vmdls that EMBED their FBX mesh compile animations whose ROOT-LEVEL bones play
        // yawed 90° about up versus the DMX's declared Y-up conversion (measured: all 25
        // re-rooted bones of a real rig at exactly (0,0,1)/90.0° in the engine, child
        // bones unaffected) - the whole character yaws/pitches in ModelDoc ("plays
        // backwards", "totally messed up") while the solve, the preview and external
        // renders of the same data are correct. Compensate on the DMX COPY only.
        var dmxFrames = target.UpAxis == TargetUpAxis.YUpCm && !string.IsNullOrEmpty(target.MeshFilePath)
            ? CompensateEmbeddedMeshRootYaw(frames, target.Rig)
            : frames;
        var dmx = DmxWriter.Write(
            target.Rig.Skeleton, new Clip(clipName, fps, looping, dmxFrames), new DmxWriteOptions
            {
                Name = clipName,
                SourceNote = isMirrored ? request.SourceFileName + " (mirrored)" : request.SourceFileName,
                // Design §3: ConstraintDriven (twist/helper) bones keep their joints +
                // bind in the DMX but get NO channels — the model's AnimConstraintList
                // drives them. Face bones are exempt from the exclusion (rest-local
                // channels): nothing drives them in a compiled sequence, and channel-less
                // face joints bake statically (eyes out of sockets in ModelDoc). Custom
                // rigs (FromSkeleton) have NO constraint list — their helpers keep baked
                // channels or twist bones freeze (candy-wrapped wrists).
                // MIRRORED clips use the IDENTICAL exclusion set (data-shape parity with
                // primary clips, southpaw G8 mirror fix): a channel-less constraint-driven
                // bone on a mirrored-body clip is no different from one on any leftward
                // primary pose; the model's AnimConstraintList drives it at runtime either
                // way. (ClipMirror.MirrorSafeExclusions remains available for consumers
                // that need reflection-exact data for excluded bones.)
                ChannelExcludedBones = target.Rig.HelpersAreConstraintDriven
                    ? context.ConstraintDrivenBones
                    : null,
                UpAxisY = target.UpAxis == TargetUpAxis.YUpCm,
            });

        var extractMotion = request.RootMotion == RootMotionMode.Extract;

        // Additive variant: '<clip>_delta' (shipped naming), collision-suffixed like every
        // other batch name. Only the NAME is produced here — the vmdl entry below reuses
        // the base clip's DMX (resourcecompiler does the reference-frame subtraction).
        var deltaName = request.CreateAdditiveVariant
            ? UniqueClipName(
                SanitizeClipName(clipName + "_delta"), usedNames, options.AutoSuffixCollisions)
            : null;

        clips.Add(new ClipResult
        {
            ClipName = clipName,
            SourceFileName = request.SourceFileName,
            SourceId = sourceId,
            DmxFileName = dmxFileName,
            DmxContent = dmx,
            Mapping = report,
            Success = true,
            SolvedFrames = frames,
            Fps = fps,
            Looping = looping,
            ExtractMotion = extractMotion,
            FootstepEvents = events,
            IsMirroredVariant = isMirrored,
            HasAdditiveVariant = deltaName is not null,
            AdditiveVariantName = deltaName,
        });
        var sourceFilename = JoinAssetPath(options.DmxFolderRelative, dmxFileName);
        entries.Add(new AnimEntry
        {
            Name = clipName,
            SourceFilename = sourceFilename,
            Looping = looping,
            ExtractMotion = extractMotion,
            Events = events,
        });
        if (deltaName is not null)
        {
            // The shipped _delta sequences carry the AnimSubtract child and nothing else
            // (no motion extraction, no events) — an additive layer fires no footsteps and
            // extracting root motion from a delta makes no sense.
            entries.Add(new AnimEntry
            {
                Name = deltaName,
                SourceFilename = sourceFilename,
                Looping = looping,
                SubtractAnimName = clipName,
                SubtractFrame = 0,
            });
        }
    }

    /// <summary>
    /// Generates the <c>AE_FOOTSTEP</c> events for a solved clip when the request asks for
    /// them (<see cref="RetargetRequest.GenerateFootstepEvents"/>): plant intervals are
    /// detected on the SOLVED TARGET frames (this is where the engine plays the clip, so
    /// touchdowns must be measured here), each interval start = one footstep
    /// (<see cref="FootstepEvents"/>). Empty when the feature is off or the target rig lacks
    /// the leg chains / character up (noted on the report then).
    /// </summary>
    private static IReadOnlyList<AnimEventEntry> GenerateFootsteps(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        MappingReportInfo report, List<XForm[]> frames, float fps)
    {
        if (!request.GenerateFootstepEvents)
            return Array.Empty<AnimEventEntry>();
        if (context.Up is not { } up || context.FootChains is not { } feet)
        {
            AddNote(report, "Footstep events skipped: " + context.UpOrChainProblem);
            return Array.Empty<AnimEventEntry>();
        }
        return FootstepEvents.Generate(
            frames, target.Rig.Skeleton, feet.Left, feet.Right, up, fps,
            ScaledPlantOptions(target));
    }

    /// <summary>Default plant thresholds are cm-tuned; engine-space rigs are in inches, so
    /// they are scaled by the cm→inch factor to keep the same physical sensitivity (shared by
    /// the foot-plant cleanup and the footstep-event detection).</summary>
    private static FootPlantOptions ScaledPlantOptions(RetargetTargetSpec target)
    {
        var options = new FootPlantOptions();
        if (target.UpAxis == TargetUpAxis.ZUpEngine)
        {
            options.SpeedThresholdCmPerSec *= RetargetTargetSpec.SboxSourceScale;
            options.HeightThresholdCm *= RetargetTargetSpec.SboxSourceScale;
        }
        return options;
    }

    private static bool MapsPinkyRole(MappingResult map)
    {
        foreach (var role in map.RoleToBone.Keys)
        {
            if (role.ToString().StartsWith("Pinky", StringComparison.Ordinal))
                return true;
        }
        return false;
    }

    /// <summary>Solve one take and run the target-space cleanup + IK baking passes.</summary>
    private static Clip SolveAndClean(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        SourceScene scene, MappingResult map, MappingReportInfo report, int take, string clipName)
    {
        var requested = request.Solve ?? new SolveOptions();
        var solver = ResolveSolver(request, target, context, report);
        var solved = solver.Solve(scene, map, target.Rig, new SolveOptions
        {
            HipScaleHorizontal = requested.HipScaleHorizontal,
            HipScaleVertical = requested.HipScaleVertical,
            TransferFingers = requested.TransferFingers,
            TransferModes = requested.TransferModes,
            ClipIndex = take,
            ClipName = clipName,
        });
        var frames = solved.Frames;

        // ---- embedded-take fidelity: authored local translations pass through ----------
        if (request.PreserveSourceTranslations)
            PassThroughSourceTranslations(scene, target.Rig, frames, take, report);

        // ---- foot-plant cleanup (target space; up axis from the TARGET character frame) ----
        if (request.FootPlantCleanup)
        {
            if (context.Up is { } up && context.FootChains is { } feet)
            {
                // Grounded stance alignment first: levels planted soles against the ground
                // (removes the stance offset a non-stance source rest leaves in the solver's
                // rest-relative foot transfer). Plants are detected on the SOURCE clip —
                // ground truth; hip-height rescaling can push the solved target trajectories
                // outside the cm-tuned Kovar thresholds. Composes with the position pinning
                // below (this rotates feet about their own joints; the pinning preserves
                // foot world rotations).
                GroundAlignFeet(frames, scene, map, target.Rig.Skeleton, feet, up, solved.Fps, take);

                FootPlant.Apply(
                    frames, target.Rig.Skeleton, feet.Left, feet.Right, up, solved.Fps,
                    ScaledPlantOptions(target));

                // Support ground alignment: one constant vertical offset per clip that
                // restores the SOURCE-authored foot-to-ground relationship (southpaw
                // final regeneration package, gate4_review.md 3.1 / DEFERRED.md f2).
                AlignSupportToGround(frames, scene, map, target.Rig, feet, up, report, take);
            }
            else
            {
                AddNote(report, "Foot-plant cleanup skipped: " + context.UpOrChainProblem);
            }
        }

        // ---- optional arm effector IK (default off: the solver already matches anatomical
        // directions; arm IK is only for reach-critical work) ----
        if (request.ArmEffectorIk)
        {
            var problem = ArmIkCleanup.Apply(frames, scene, map, target.Rig, context, take);
            if (problem is not null)
                AddNote(report, "Arm effector IK skipped: " + problem);
        }

        // ---- root motion (see class remarks for the Extract ↔ ExtractMotion mapping) ----
        ApplyRootMotion(request.RootMotion, frames, context, report);

        // ---- IK helper bones (root_IK, IK targets, ikrule) need real baked channels ----
        if (context.HasIkBakedBones)
            IkBoneBaker.Bake(frames, target.Rig);

        // ---- unmapped limb twist bones follow their joint's roll ------------------------
        // (on shipped s&box rigs the model's own AnimConstraintList owns those instead)
        var twistCount = TwistBoneFollow.Apply(frames, target.Rig,
            target.Rig.HelpersAreConstraintDriven ? context.ConstraintDrivenBones : null);
        if (twistCount > 0)
            AddNote(report, $"{twistCount} limb twist bone(s) follow their joint's roll "
                + "(left at rest they candy-wrap the skin at wrists/knees).");

        // ---- orphan helper bones ride the hips (LAST: anchored to the FINAL hips, after
        // foot-plant pinning / root motion have settled the trajectory) -------------------
        FollowHipsWithOrphanBones(target.Rig, frames, report);

        var looping = request.LoopingOverride ?? solved.Looping;
        return new Clip(clipName, solved.Fps, looping, frames);
    }

    /// <summary>
    /// Unmapped bone subtrees OUTSIDE the mapped skeleton (no mapped ancestor — cloth and
    /// physics helpers parented to the scene root; real case: a skirt of 48 kilt bones on
    /// a Sketchfab catgirl, siblings of the body root) ride the character rigidly. Left
    /// at their rest locals they freeze at the bind spot while the body animates away
    /// ("one part of the body doesn't move at all") and the mesh skinned across the
    /// boundary shears ("completely stretched"). Each subtree's topmost bone anchors to
    /// the HIPS — or to an extremity (hand/foot/head) when its rest sits clearly closer
    /// to it (a sword resting in the hand follows the hand, not the pelvis). Descendants
    /// ride along on their rest locals; bones on the hips' own ancestor path stay
    /// solver-owned (trajectory).
    /// </summary>
    private static void FollowHipsWithOrphanBones(
        TargetRig rig, List<XForm[]> frames, MappingReportInfo report)
    {
        var skeleton = rig.Skeleton;
        if (rig.BoneForRole(BoneRole.Hips) is not { } hips || frames.Count == 0)
            return;

        var hipsPath = new HashSet<int>();
        for (var b = hips; b >= 0; b = skeleton[b].ParentIndex)
            hipsPath.Add(b);

        bool HasMappedAncestor(int bone)
        {
            for (var b = skeleton[bone].ParentIndex; b >= 0; b = skeleton[b].ParentIndex)
            {
                if (rig.RoleOf(b) is not null)
                    return true;
            }
            return false;
        }

        var rest = skeleton.RestWorld;

        // Extremity anchors: adopted only when the orphan rests at less than half its
        // hips distance — near-torso helpers (skirts, tails, capes) stay on the hips,
        // which never mistakes a skirt panel brushing a thigh for hand luggage.
        var extremities = new List<int>();
        foreach (var role in new[]
        {
            BoneRole.HandL, BoneRole.HandR, BoneRole.FootL, BoneRole.FootR, BoneRole.Head,
        })
        {
            if (rig.BoneForRole(role) is { } bone)
                extremities.Add(bone);
        }

        // Topmost orphans: unmapped, off the hips' ancestor path, no mapped ancestor,
        // and the parent is either the scene root path or absent (children of deeper
        // orphans ride along unchanged).
        var tops = new List<(int Bone, int Anchor)>();
        for (var i = 0; i < skeleton.Count; i++)
        {
            if (rig.RoleOf(i) is not null || hipsPath.Contains(i) || HasMappedAncestor(i))
                continue;
            var parent = skeleton[i].ParentIndex;
            if (parent >= 0 && !hipsPath.Contains(parent))
                continue;

            var anchor = hips;
            var hipsDistance = (rest[i].Pos - rest[hips].Pos).Length();
            var best = hipsDistance * 0.5f;
            foreach (var candidate in extremities)
            {
                var distance = (rest[i].Pos - rest[candidate].Pos).Length();
                if (distance < best)
                {
                    best = distance;
                    anchor = candidate;
                }
            }
            tops.Add((i, anchor));
        }
        if (tops.Count == 0)
            return;

        var anchorRestInverse = new Dictionary<int, XForm>();
        foreach (var (_, anchor) in tops)
            anchorRestInverse[anchor] = rest[anchor].Inverse();

        foreach (var frame in frames)
        {
            var world = new Skeleton.Pose(frame).ToWorld(skeleton);
            foreach (var (top, anchor) in tops)
            {
                var delta = XForm.Compose(world[anchor], anchorRestInverse[anchor]);
                var parent = skeleton[top].ParentIndex;
                var parentWorld = parent >= 0 ? world[parent] : XForm.Identity;
                frame[top] = XForm.ToLocal(
                    parentWorld, XForm.Compose(delta, rest[top]));
            }
        }
        AddNote(report,
            $"{tops.Count} helper bone subtree(s) outside the mapped skeleton ride the "
            + "character (cloth/physics/prop bones would otherwise freeze at their bind position).");
    }

    /// <summary>
    /// The engine-side yaw correction for embedded-mesh vmdls (see the call site in
    /// <see cref="EmitClip"/>): the compiler's Y-up→engine conversion for ROOT-LEVEL
    /// animation channels lands 90° about engine-up away from where it puts the mesh
    /// bind. An engine-up (+Z) yaw conjugated through the Y-up conversion is a yaw
    /// about rig up (+Y): pre-rotating root-level locals by −90° about rig Y makes the
    /// compiled playback match the compiled bind. Child bones are parent-relative and
    /// need nothing.
    /// </summary>
    private static List<XForm[]> CompensateEmbeddedMeshRootYaw(
        IReadOnlyList<XForm[]> frames, TargetRig rig)
    {
        var yaw = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -MathF.PI * 0.5f);
        var skeleton = rig.Skeleton;
        var result = new List<XForm[]>(frames.Count);
        foreach (var frame in frames)
        {
            var copy = new XForm[frame.Length];
            for (var i = 0; i < frame.Length; i++)
            {
                copy[i] = i < skeleton.Count && skeleton[i].ParentIndex < 0
                    ? new XForm(
                        Vector3.Transform(frame[i].Pos, yaw),
                        MathQ.Normalize(yaw * frame[i].Rot))
                    : frame[i];
            }
            result.Add(copy);
        }
        return result;
    }

    /// <summary>Test seam for <see cref="CompensateEmbeddedMeshRootYaw"/> (gate bisects
    /// re-serialize mutated frames with the same compensation the pipeline applies).</summary>
    public static List<XForm[]> TestHook_CompensateEmbeddedMeshRootYaw(
        IReadOnlyList<XForm[]> frames, RetargetTargetSpec target)
        => target.UpAxis == TargetUpAxis.YUpCm && !string.IsNullOrEmpty(target.MeshFilePath)
            ? CompensateEmbeddedMeshRootYaw(frames, target.Rig)
            : frames.ToList();

    /// <summary>Test seam for <see cref="FollowHipsWithOrphanBones"/>.</summary>
    public static void TestHook_FollowOrphans(TargetRig rig, List<XForm[]> frames)
        => FollowHipsWithOrphanBones(rig, frames, new MappingReportInfo
        {
            ProfileName = "test",
            Source = MappingSource.Manual,
            Confidence = 1f,
            NeedsUserDecision = false,
            MappedRoleCount = 0,
            SkeletonSignature = "",
        });

    /// <summary>
    /// <see cref="RetargetRequest.PreserveSourceTranslations"/>: per-frame source local
    /// translations onto same-named target bones. Names are compared engine-sanitized
    /// (non-alphanumerics → <c>_</c>: the compiled-model rig the editor rebuilds carries
    /// <c>Bip01_R_Hand</c> for the file's <c>Bip01 R Hand</c>). A matched bone whose REST
    /// translation disagrees between the rigs is skipped — the name collision is then
    /// coincidence, not the same joint. The hips and its ancestors always stay
    /// solver-owned (trajectory, root-motion modes, hip rescale).
    /// </summary>
    private static void PassThroughSourceTranslations(
        SourceScene scene, TargetRig rig, List<XForm[]> frames, int take,
        MappingReportInfo report)
    {
        var src = scene.Skeleton;
        var tgt = rig.Skeleton;
        var srcClip = scene.Clips[take];
        if (srcClip.Frames.Count != frames.Count)
        {
            AddNote(report, "Source translations not preserved: frame counts differ "
                + $"({srcClip.Frames.Count} source vs {frames.Count} solved).");
            return;
        }

        static string Key(string name)
        {
            Span<char> c = stackalloc char[name.Length];
            for (var i = 0; i < name.Length; i++)
                c[i] = char.IsLetterOrDigit(name[i]) ? char.ToLowerInvariant(name[i]) : '_';
            return new string(c);
        }

        var srcByName = new Dictionary<string, int>();
        for (var i = 0; i < src.Count; i++)
        {
            // Duplicate keys are ambiguous - poison them rather than guess.
            var key = Key(src[i].Name);
            srcByName[key] = srcByName.ContainsKey(key) ? -1 : i;
        }

        var excluded = new HashSet<int>();
        if (rig.BoneForRole(BoneRole.Hips) is { } hips)
            for (var b = hips; b >= 0; b = tgt[b].ParentIndex)
                excluded.Add(b);

        var applied = 0;
        for (var t = 0; t < tgt.Count; t++)
        {
            if (excluded.Contains(t)
                || !srcByName.TryGetValue(Key(tgt[t].Name), out var s) || s < 0)
                continue;
            // Same joint check: rest translations must agree (same rig, same space).
            var restDelta = (src[s].RestLocal.Pos - tgt[t].RestLocal.Pos).Length();
            if (restDelta > MathF.Max(1f, 0.1f * tgt[t].RestLocal.Pos.Length()))
                continue;
            for (var f = 0; f < frames.Count; f++)
                frames[f][t] = new XForm(srcClip.Frames[f][s].Pos, frames[f][t].Rot);
            applied++;
        }
        if (applied > 0)
            AddNote(report, $"Embedded take: authored local translations preserved on {applied} bones.");
    }

    /// <summary>
    /// Picks the solver for a request (design §10 routing): the
    /// <see cref="GeometricSolver"/> unless the request selects
    /// <see cref="SolverKind.DeepLearning"/>, which needs the spec's
    /// <see cref="RetargetTargetSpec.DlWeights"/> and is built once per batch (the parsed
    /// model is cached on the <see cref="TargetContext"/>). A DL request also notes that
    /// per-role mapping is ignored by that solver.
    /// </summary>
    /// <exception cref="InvalidOperationException">Thrown (and surfaced as the clip's
    /// error) when DL is requested without weights.</exception>
    private static IRetargetSolver ResolveSolver(
        RetargetRequest request, RetargetTargetSpec target, TargetContext context,
        MappingReportInfo report)
    {
        if (request.Solver != SolverKind.DeepLearning)
            return new GeometricSolver();

        if (target.DlWeights is not { Length: > 0 })
        {
            throw new InvalidOperationException(
                "Deep-learning solver requested but RetargetTargetSpec.DlWeights is not set "
                + "(read Assets/humanoid_retargeter/dl/same_v1.weights and pass its bytes).");
        }

        AddNote(report,
            "Deep-learning solver (SAME, experimental): skeleton-agnostic — per-role mapping "
            + "is not used (hips/alignment heuristics only); finger bones stay at rest.");
        return context.GetDlSolver(target.DlWeights);
    }

    /// <summary>
    /// Normalized-rest foot pitch beyond which (toe approaching/above ankle level) the rest
    /// cannot be a flat stance — measured stance rests sit at −11°…−51°; the repro artifact
    /// rig measured +2.7° on one foot.
    /// </summary>
    private const float StancePitchMaxDeg = -5f;

    /// <summary>Left/right normalized-rest foot pitch asymmetry beyond which the rest is not
    /// a (symmetric) stance — measured stance rests differ ≤ 2.4°; the repro artifact rig
    /// 13.8°.</summary>
    private const float StancePitchAsymmetryDeg = 6f;

    /// <summary>
    /// The grounded-foot stance recalibration step (<see cref="FootGroundAlign"/>). Runs ONLY
    /// when the source's NORMALIZED rest — the solver's delta reference — is implausible as a
    /// flat stance (a foot's toe at/above ankle level, or the two feet resting asymmetrically;
    /// both measured on the repro rig, where T-pose normalization of a bent-leg rest swings
    /// the feet 14–26° away from the rig's true stance). On plausible stance rests the solver's
    /// rest-relative foot transfer is already faithful, and planted-sole deviations are
    /// genuine articulation (boxing stances, heel rolls) that must NOT be flattened. Plant
    /// intervals are detected on the SOURCE clip (cm space, default Kovar thresholds).
    /// Best-effort — silently skipped when the source maps no complete leg chains + toes or a
    /// character frame is not computable (the foot transfer is then simply left as solved).
    /// </summary>
    private static void GroundAlignFeet(
        List<XForm[]> frames, SourceScene scene, MappingResult map, SkeletonModel targetSkeleton,
        (FootChain Left, FootChain Right) targetFeet, Vector3 targetUp, float fps, int take)
    {
        FootChain? SourceChain(BoneRole upper, BoneRole lower, BoneRole foot, BoneRole toe)
            => map.RoleToBone.TryGetValue(upper, out var hip)
               && map.RoleToBone.TryGetValue(lower, out var knee)
               && map.RoleToBone.TryGetValue(foot, out var ankle)
                ? new FootChain
                {
                    Hip = hip,
                    Knee = knee,
                    Ankle = ankle,
                    Toe = map.RoleToBone.TryGetValue(toe, out var t) ? t : null,
                }
                : null;

        var srcLeft = SourceChain(BoneRole.UpperLegL, BoneRole.LowerLegL, BoneRole.FootL, BoneRole.ToeL);
        var srcRight = SourceChain(BoneRole.UpperLegR, BoneRole.LowerLegR, BoneRole.FootR, BoneRole.ToeR);
        if (srcLeft?.Toe is not int srcToeL || srcRight?.Toe is not int srcToeR)
            return; // toe-less sources take the solver's virtual-foot fallback instead

        // ---- rest-stance plausibility (normalized rest, the solver's delta reference) ----
        float pitchL, pitchR;
        Vector3 srcUp;
        try
        {
            var (srcNorm, _) = RestNormalizer.Normalize(scene.Skeleton, map);
            var srcCanon = CanonicalFrames.Build(scene.Skeleton, map, srcNorm.WorldRest);
            srcUp = Vector3.Normalize(srcCanon.CharacterUp);

            float RestPitchDeg(int foot, int toe)
            {
                var dir = srcNorm.WorldRest[toe].Pos - srcNorm.WorldRest[foot].Pos;
                if (dir.LengthSquared() < 1e-8f)
                    return 0f;
                var s = Vector3.Dot(Vector3.Normalize(dir), srcUp);
                return MathF.Asin(Math.Clamp(s, -1f, 1f)) * (180f / MathF.PI);
            }

            pitchL = RestPitchDeg(srcLeft.Ankle, srcToeL);
            pitchR = RestPitchDeg(srcRight.Ankle, srcToeR);
        }
        catch (ArgumentException)
        {
            return;
        }

        var restIsStance = pitchL < StancePitchMaxDeg && pitchR < StancePitchMaxDeg
            && MathF.Abs(pitchL - pitchR) <= StancePitchAsymmetryDeg;
        if (restIsStance)
            return;

        var srcFrames = scene.Clips[take].Frames;
        var (plantsL, plantsR) = FootPlant.DetectPlantIntervals(
            srcFrames, scene.Skeleton, srcLeft, srcRight, srcUp, fps);
        if (plantsL.Count == 0 && plantsR.Count == 0)
            return;

        FootGroundAlign.Apply(
            frames, targetSkeleton, targetFeet.Left, targetFeet.Right, targetUp,
            plantsL, plantsR);
    }

    /// <summary>
    /// Restores the clip's SOURCE-authored foot-to-ground relationship with ONE constant
    /// vertical offset per clip (southpaw final regeneration package: DEFERRED.md f2 and
    /// gate4_review.md 3.1). The solver's vertical reference is the source REST pelvis;
    /// mid-pose exports whose kept node rest erased the authored crouch leave every solved
    /// frame hovering by exactly the erased crouch depth (measured: stance +5.9 cm, steps
    /// +7.8 to +10.3, slips ~+9, while the source FBX is grounded everywhere). Fix, the
    /// generalization of the BVH ClipPlacementOffset vertical logic to every source: measure
    /// the clip's minimum foot support (ankles + toes) in the SOLVED target frames and shift
    /// the whole clip vertically so that support equals the TARGET rest support plus the
    /// source clip's own authored support delta (source clip support minus source rest
    /// support, hip-height scaled). One constant per clip preserves every within-clip lift,
    /// heel raise and step cycle exactly: authored articulation is never flattened and an
    /// authored jump stays airborne (its source support delta rides along).
    /// </summary>
    private static void AlignSupportToGround(
        List<XForm[]> frames, SourceScene scene, MappingResult map, TargetRig rig,
        (FootChain Left, FootChain Right) feet, Vector3 up, MappingReportInfo report, int take)
    {
        if (frames.Count == 0)
            return;
        var skeleton = rig.Skeleton;
        var rest = skeleton.RestWorld;
        var srcClip = scene.Clips[take];
        var src = scene.Skeleton;

        // The GROUND normal is the rig-space vertical axis, not the character-lean up:
        // the character frame's up tilts a few degrees with the rest posture, and a
        // tilted dot-metric slants the ground line across a traveling clip (measured:
        // 28 cm of false offset on a long corpus walk). Snap to the dominant axis.
        var aUp = Vector3.Abs(up);
        up = aUp.X >= aUp.Y && aUp.X >= aUp.Z
            ? new Vector3(MathF.Sign(up.X), 0f, 0f)
            : aUp.Y >= aUp.Z
                ? new Vector3(0f, MathF.Sign(up.Y), 0f)
                : new Vector3(0f, 0f, MathF.Sign(up.Z));

        // ---- support-bone set: the roles mapped on BOTH rigs (metrics must compare
        // the same anatomical points or anthropometric offsets leak into the gap) ----
        var supportRoles = new List<BoneRole>();
        foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR, BoneRole.ToeL, BoneRole.ToeR })
        {
            if (map.RoleToBone.ContainsKey(role) && rig.BoneForRole(role) is not null)
                supportRoles.Add(role);
        }
        var sourceMeasured = supportRoles.Contains(BoneRole.FootL) && supportRoles.Contains(BoneRole.FootR)
            && srcClip.Frames.Count > 0 && srcClip.Frames[0].Length == src.Count;
        if (!sourceMeasured)
        {
            // No reliable common support metric: fall back to the ankles the target
            // chains provide and a zero source gap (best guess: grounded).
            supportRoles.Clear();
        }

        var tgtSupportBones = new List<int>();
        foreach (var role in supportRoles)
            tgtSupportBones.Add(rig.BoneForRole(role)!.Value);
        if (tgtSupportBones.Count == 0)
        {
            tgtSupportBones.Add(feet.Left.Ankle);
            tgtSupportBones.Add(feet.Right.Ankle);
        }

        var tgtRestSupport = float.MaxValue;
        foreach (var b in tgtSupportBones)
            tgtRestSupport = MathF.Min(tgtRestSupport, Vector3.Dot(rest[b].Pos, up));

        var solvedSupport = float.MaxValue;
        foreach (var frame in frames)
        {
            var world = new Skeleton.Pose(frame).ToWorld(skeleton);
            foreach (var b in tgtSupportBones)
                solvedSupport = MathF.Min(solvedSupport, Vector3.Dot(world[b].Pos, up));
        }

        // ---- source-authored support gap --------------------------------------
        float sourceRel = 0f;
        if (sourceMeasured)
        {
            var srcSupportBones = new List<int>();
            foreach (var role in supportRoles)
                srcSupportBones.Add(map.RoleToBone[role]);

            var srcUp = scene.UpAxis switch
            {
                0 => new Vector3(scene.UpAxisSign, 0f, 0f),
                2 => new Vector3(0f, 0f, scene.UpAxisSign),
                _ => new Vector3(0f, scene.UpAxisSign, 0f),
            };
            var srcHips = map.RoleToBone.TryGetValue(BoneRole.Hips, out var sh) ? sh : -1;

            var srcRestSupport = float.MaxValue;
            var srcRestGround = float.MaxValue;
            for (var b = 0; b < src.Count; b++)
            {
                var h = Vector3.Dot(src.RestWorld[b].Pos, srcUp);
                srcRestGround = MathF.Min(srcRestGround, h);
            }
            foreach (var b in srcSupportBones)
                srcRestSupport = MathF.Min(srcRestSupport, Vector3.Dot(src.RestWorld[b].Pos, srcUp));

            var srcClipSupport = float.MaxValue;
            var srcMotionGround = float.MaxValue;
            foreach (var frame in srcClip.Frames)
            {
                var world = new Skeleton.Pose(frame).ToWorld(src);
                foreach (var b in srcSupportBones)
                    srcClipSupport = MathF.Min(srcClipSupport, Vector3.Dot(world[b].Pos, srcUp));
                if (!scene.RestPlacementAuthored)
                {
                    for (var b = 0; b < src.Count; b++)
                        srcMotionGround = MathF.Min(srcMotionGround, Vector3.Dot(world[b].Pos, srcUp));
                }
            }

            // AUTHORED placements (FBX): the rest stands on the authored ground, so the
            // gap is simply clip support minus rest support (same bones, same space).
            // UN-PLACED sources (BVH capture volumes): absolute placement is meaningless,
            // so both terms go placement-free: the clip's support above its OWN motion
            // ground (lowest joint anywhere, incl. static origin markers) compared to the
            // rest pose's support above ITS lowest joint. A grounded walk reads ~0, a
            // jump reads its true airborne height, a crawl reads its raised feet.
            float gap;
            if (scene.RestPlacementAuthored)
                gap = srcClipSupport - srcRestSupport;
            else
                gap = (srcClipSupport - srcMotionGround) - (srcRestSupport - srcRestGround);

            // Hip-height ratio scales the (small) source gap into target units,
            // clamped against degenerate rest measurements.
            var ratio = 1f;
            if (srcHips >= 0 && rig.BoneForRole(BoneRole.Hips) is { } tgtHips)
            {
                var srcHipHeight = Vector3.Dot(src.RestWorld[srcHips].Pos, srcUp) - srcRestSupport;
                var tgtHipHeight = Vector3.Dot(rest[tgtHips].Pos, up) - tgtRestSupport;
                if (srcHipHeight > 1e-3f && float.IsFinite(tgtHipHeight / srcHipHeight))
                    ratio = Math.Clamp(tgtHipHeight / srcHipHeight, 0.25f, 4f);
            }
            sourceRel = gap * ratio;
        }

        var delta = tgtRestSupport + sourceRel - solvedSupport;
        if (!float.IsFinite(delta) || MathF.Abs(delta) > 100f)
        {
            AddNote(report, $"Support ground alignment skipped: implausible offset {delta:0.0}.");
            return;
        }
        if (MathF.Abs(delta) < 0.01f)
            return; // already aligned

        var shift = up * delta;
        foreach (var frame in frames)
        {
            for (var i = 0; i < skeleton.Count; i++)
            {
                if (skeleton[i].ParentIndex < 0)
                    frame[i] = new XForm(frame[i].Pos + shift, frame[i].Rot);
            }
        }
        AddNote(report, sourceMeasured
            ? $"Support ground alignment: {delta:0.00} vertical offset applied "
              + $"(source-authored support delta {sourceRel:0.00})."
            : $"Support ground alignment: {delta:0.00} vertical offset applied "
              + "(source feet unmapped; clip support aligned to target rest support).");
    }

    private static void ApplyRootMotion(
        RootMotionMode mode, List<XForm[]> frames, TargetContext context, MappingReportInfo report)
    {
        if (mode == RootMotionMode.Off)
            return;

        if (context.HipsIndex is not { } hips)
        {
            AddNote(report, $"Root motion ({mode}) skipped: target rig maps no Hips role.");
            return;
        }
        if (context.Up is not { } up)
        {
            AddNote(report, $"Root motion ({mode}) skipped: {context.UpOrChainProblem}");
            return;
        }

        if (mode == RootMotionMode.Extract && context.DedicatedRootIndex is null)
        {
            // s&box-style target: pelvis is parentless and there is no separate translating
            // root bone (root_IK is IkBaked). Bone-level extraction is impossible — the vmdl
            // AnimFile entry carries an ExtractMotion node instead (compile-time extraction).
            AddNote(report,
                "Root-motion extract: target has no dedicated animated root bone distinct from "
                + "the hips; frames left as solved and motion extraction delegated to the vmdl "
                + "AnimFile ExtractMotion node.");
            return;
        }

        var root = context.DedicatedRootIndex ?? hips;
        // Skeleton-aware overload: hips world via real parent-chain FK, locals re-derived
        // against the actual parent (HipsParentIsRoot is ignored by this overload).
        RootMotion.Apply(frames, context.Rig.Skeleton, new RootMotionAxes
        {
            Up = up,
            RootIndex = root,
            HipsIndex = hips,
            HipsParentIsRoot = context.HipsParentIsRoot,
        }, mode);
        AddNote(report, mode == RootMotionMode.Extract
            ? $"Root motion extracted onto dedicated root bone (index {root})."
            : "Root motion removed (in-place clip).");
    }

    private static void AddNote(MappingReportInfo report, string note)
    {
        if (!report.Notes.Contains(note))
            report.Notes.Add(note);
    }

    /// <summary>
    /// Mid-pose FBX sources: adopts the importer's bind-rest alternate skeleton when it
    /// makes the character stand STRAIGHTER along the file's own up axis. Solving on a
    /// posed rest breaks the solver's anatomical reasoning on exactly the posed limbs
    /// (measured 48° mean world-rot off ground truth on a real rig, left foot 124°); but
    /// a bind rest can also tilt the character-up estimate (shoulders behind hips on one
    /// real rig tilted it 7°, starving plant/stance detection), so the straighter rest
    /// wins. Clips are authored playback either way — same bones, same order.
    /// </summary>
    private static SourceScene MaybeAdoptBindRest(
        SourceScene scene, MappingResult map, MappingReportInfo report)
    {
        if (scene.MidPoseBindSkeleton is not { } bindSkeleton)
            return scene;

        var fileUp = scene.UpAxis switch
        {
            0 => new Vector3(scene.UpAxisSign, 0, 0),
            2 => new Vector3(0, 0, scene.UpAxisSign),
            _ => new Vector3(0, scene.UpAxisSign, 0),
        };

        float UpTiltDeg(SkeletonModel skeleton)
        {
            var (norm, _) = RestNormalizer.Normalize(skeleton, map);
            var up = Vector3.Normalize(
                CanonicalFrames.Build(skeleton, map, norm.WorldRest).CharacterUp);
            return MathF.Acos(Math.Clamp(Vector3.Dot(up, fileUp), -1f, 1f)) * 180f / MathF.PI;
        }

        try
        {
            float tiltStatics = UpTiltDeg(scene.Skeleton);
            float tiltBind = UpTiltDeg(bindSkeleton);
            if (tiltBind > tiltStatics + 1f)
            {
                AddNote(report,
                    $"mid-pose export: kept the exported pose as rest - the file's bind "
                    + $"data stands {tiltBind:0.#} deg off the file up axis "
                    + $"(vs {tiltStatics:0.#} deg)");
                return scene;
            }

            AddNote(report,
                "mid-pose export: rest pose restored from the file's own bind data "
                + "(clips keep their authored playback)");
            return new SourceScene(
                bindSkeleton, scene.Clips, scene.UnitScaleCm,
                scene.UpAxis, scene.UpAxisSign,
                scene.FrontAxis, scene.FrontAxisSign,
                scene.CoordAxis, scene.CoordAxisSign,
                scene.OriginalUpAxis, scene.Notes)
            {
                AuthoredMapping = scene.AuthoredMapping,
                RestPlacementAuthored = scene.RestPlacementAuthored,
            };
        }
        catch (Exception)
        {
            // Anatomy not measurable (mapping lacks the frame's roles, degenerate rest):
            // keep today's behavior.
            return scene;
        }
    }

    // ================================================================ import + mapping

    /// <summary>
    /// Imports source-file bytes exactly like conversion does: the extension picks the
    /// importer; unknown extensions fall back to content sniffing (FBX binary magic / ASCII
    /// header token / BVH "HIERARCHY" / GLB 'glTF' magic / glTF JSON). Public so UI listings
    /// inspect files through the SAME import path the pipeline uses (no duplicate sniffing
    /// logic caller-side).
    /// </summary>
    /// <param name="data">Raw file bytes.</param>
    /// <param name="fileName">File name; only the extension is consulted.</param>
    /// <param name="sampleFps">Resample rate for the imported clips; null = importer default
    /// (30 fps).</param>
    /// <param name="skeletonData">Companion skeleton bytes for animation-only formats
    /// (RenderWare .anm/.an5 need the model .dff — see
    /// <see cref="RetargetRequest.SkeletonData"/>); ignored by self-contained formats.</param>
    /// <exception cref="FormatException">Thrown when the bytes are not a readable
    /// FBX/BVH/glTF/RenderWare animation.</exception>
    public static SourceScene ImportSource(
        byte[] data, string fileName, float? sampleFps = null, byte[]? skeletonData = null)
    {
        ArgumentNullException.ThrowIfNull(data);
        ArgumentNullException.ThrowIfNull(fileName);

        var fbxOptions = sampleFps is { } fbxFps ? new FbxImportOptions { SampleFps = fbxFps } : null;
        var bvhOptions = sampleFps is { } bvhFps ? new BvhImportOptions { SampleFps = bvhFps } : null;
        var gltfOptions = sampleFps is { } gltfFps ? new GltfImportOptions { SampleFps = gltfFps } : null;
        // RenderWare banks carry no clip names — takes are named from the file stem.
        var rwOptions = new RwAnmImportOptions
        {
            SampleFps = sampleFps ?? 30f,
            ClipNameBase = FileStem(fileName),
        };
        var ext = ExtensionOf(fileName);
        return ext switch
        {
            "fbx" => FbxImporter.Import(data, fbxOptions),
            "bvh" => BvhImporter.Import(data, bvhOptions),
            // .vrm = glTF 2.0 GLB container + VRM extension (the importer reads the authored
            // humanoid bone map into SourceScene.AuthoredMapping); unknown-extension VRM
            // bytes also land here via the GLB magic sniff below.
            "glb" or "gltf" or "vrm" => GltfImporter.Import(data, gltfOptions),
            // RenderWare animations (FSB2 .anm single clips / .an5 banks); the skeleton
            // comes from the companion model .dff (a missing skeleton throws the
            // importer's instructive error).
            "anm" or "an5" => RwAnmImporter.Import(data, skeletonData, rwOptions),
            _ => SniffFormat(data) switch
            {
                "fbx" => FbxImporter.Import(data, fbxOptions),
                "bvh" => BvhImporter.Import(data, bvhOptions),
                "gltf" => GltfImporter.Import(data, gltfOptions),
                "rwanim" => RwAnmImporter.Import(data, skeletonData, rwOptions),
                _ => throw new FormatException(
                    $"Unrecognized source format for '{fileName}' (expected .fbx, .bvh, .glb, .gltf, .vrm, .anm or .an5)."),
            },
        };
    }

    private static string? SniffFormat(byte[] data)
    {
        // Binary FBX magic: "Kaydara FBX Binary  \0".
        const string fbxMagic = "Kaydara FBX Binary";
        if (StartsWithAscii(data, fbxMagic))
            return "fbx";

        // GLB magic: 'glTF' (0x46546C67 little-endian).
        if (StartsWithAscii(data, "glTF"))
            return "gltf";

        var head = Encoding.UTF8.GetString(data, 0, Math.Min(data.Length, 4096));
        if (head.Contains("FBXHeaderExtension", StringComparison.Ordinal))
            return "fbx"; // ASCII FBX
        var trimmed = head.TrimStart();
        if (trimmed.StartsWith("HIERARCHY", StringComparison.OrdinalIgnoreCase))
            return "bvh";
        if (trimmed.StartsWith("{", StringComparison.Ordinal)
            && head.Contains("\"asset\"", StringComparison.Ordinal))
            return "gltf"; // plain-JSON glTF

        // RenderWare animation stream (.anm single clip / .an5 take container).
        if (RwAnmImporter.LooksLikeRenderwareAnim(data))
            return "rwanim";
        return null;
    }

    private static bool StartsWithAscii(byte[] data, string prefix)
    {
        if (data.Length < prefix.Length)
            return false;
        for (var i = 0; i < prefix.Length; i++)
        {
            if (data[i] != (byte)prefix[i])
                return false;
        }
        return true;
    }

    /// <summary>
    /// THE mapping cascade, shared by conversion, UI file listings, and custom-target
    /// detection: explicit override → authored mapping (from the file itself, e.g. a VRM's
    /// humanoid bone map — authoritative ground truth at confidence 1.0) → user preset (via
    /// <paramref name="userPresetLookup"/>, keyed by <see cref="Mapping.SkeletonSignature"/>)
    /// → shipped preset detection → best-effort auto-map. The report's
    /// <see cref="MappingReportInfo.NeedsUserDecision"/> is true only on the auto path below
    /// the preset detection threshold — conversion still proceeds with that map; callers
    /// decide whether to ask/reject.
    /// </summary>
    /// <param name="skeleton">Source (or candidate target) skeleton.</param>
    /// <param name="mappingOverride">Explicit mapping (manual table / already-resolved user
    /// preset); wins outright when non-null — a deliberate user decision beats even the
    /// file's own bone map.</param>
    /// <param name="userPresetLookup">Editor-side user-preset hook: receives the skeleton's
    /// signature, returns the stored mapping or null. The facade itself can do no file IO.</param>
    /// <param name="authoredMapping">A mapping authored INSIDE the source file
    /// (<see cref="SourceScene.AuthoredMapping"/>, <see cref="MappingSource.Authored"/>);
    /// consulted before user presets because the file itself is authoritative.</param>
    public static (MappingResult Map, MappingReportInfo Report) ResolveMapping(
        SkeletonModel skeleton, MappingResult? mappingOverride = null,
        Func<string, MappingResult?>? userPresetLookup = null,
        MappingResult? authoredMapping = null)
    {
        ArgumentNullException.ThrowIfNull(skeleton);

        if (mappingOverride is not null)
            return (mappingOverride, BuildReport(mappingOverride, needsUserDecision: false, skeleton));

        if (authoredMapping is not null)
            return (authoredMapping, BuildReport(authoredMapping, needsUserDecision: false, skeleton));

        if (userPresetLookup is not null
            && userPresetLookup(Mapping.SkeletonSignature.Compute(skeleton)) is { } userPreset)
            return (userPreset, BuildReport(userPreset, needsUserDecision: false, skeleton));

        if (ProfileDetector.Detect(skeleton) is { } detected)
        {
            VetoImpossibleHead(skeleton, detected.Result);
            return (detected.Result, BuildReport(detected.Result, needsUserDecision: false, skeleton));
        }

        var auto = AutoMapper.Map(skeleton);
        VetoImpossibleHead(skeleton, auto);
        var needsUserDecision = auto.Confidence < ProfileDetector.DetectionThreshold;
        return (auto, BuildReport(auto, needsUserDecision, skeleton));
    }

    /// <summary>
    /// Drops a detected/auto-mapped Head whose bone cannot anatomically drive a skull: its
    /// rest sits clearly BELOW the neck (or the topmost mapped spine joint). Real case: an
    /// Auto-Rig Pro export parks <c>head.x</c> at chest height ~90cm under <c>neck.x</c>
    /// with the skin bind compensating — the mesh is correct at rest, but every skull
    /// rotation replayed onto that bone sweeps the head geometry on the lever arm ("the
    /// head is stretched and it stretched more when I played the animation"). With the
    /// role unmapped the neck — whose pivot IS at the skull base on such rigs — carries
    /// head motion, and the bone holds its rest like any other unmapped helper.
    /// Neutral-rest corpus rigs measure the neck→head segment −8°…33° from character up
    /// (dot ≥ 0.55 even on the posed Defenses bind at 40.7°); the veto fires only past
    /// 101° (dot &lt; −0.2), an impossible carriage on any working rig. Explicit,
    /// authored and user-preset mappings are authoritative and never second-guessed.
    /// </summary>
    private static void VetoImpossibleHead(SkeletonModel skeleton, MappingResult map)
    {
        if (!map.RoleToBone.TryGetValue(BoneRole.Head, out var head)
            || !map.RoleToBone.TryGetValue(BoneRole.Hips, out var hips))
            return;

        var reference = map.RoleToBone.TryGetValue(BoneRole.Neck, out var neck)
            ? neck
            : SpineTop(map);
        if (reference is not { } refBone || refBone == hips)
            return;

        var rest = skeleton.RestWorld;
        // Axis-free "up": the body chain ascends hips → spine → neck by construction, so
        // hips→reference is the rig's own vertical regardless of world axis convention.
        var up = rest[refBone].Pos - rest[hips].Pos;
        var seg = rest[head].Pos - rest[refBone].Pos;
        if (up.LengthSquared() < 1e-6f || seg.Length() < 0.05f * up.Length())
            return; // degenerate spine / head stacked on the neck: nothing to judge

        if (Vector3.Dot(Vector3.Normalize(seg), Vector3.Normalize(up)) >= -0.2f)
            return;

        map.RoleToBone.Remove(BoneRole.Head);
        // The neck of a broken skull region rests with the head. On the real case the
        // neck bone skins the skull AND a torso column (its weights are where the head's
        // should be), so any solved neck bend shears half the body: the source spreads
        // its idle hunch through its spine while this target's spine stays put, and the
        // faithful neck world replay turned into a ~19° LOCAL kink rendered as a hump
        // grafted onto the character's back. Skull attitude rides the spine instead.
        var neckNote = "";
        if (map.RoleToBone.Remove(BoneRole.Neck))
            neckNote = $" Its neck '{skeleton[neck].Name}' rests too (on such exports it "
                + "carries the skull/torso skin the head joint should).";
        map.Notes.Add(
            $"Head '{skeleton[head].Name}' unmapped: its rest sits below "
            + $"'{skeleton[refBone].Name}' - not a skull joint the solver can drive "
            + "(rotations would sweep the head geometry on the offset lever arm)."
            + neckNote + " The spine carries the upper body; map manually to override.");
    }

    private static int? SpineTop(MappingResult map)
    {
        foreach (var role in new[]
        {
            BoneRole.Spine4, BoneRole.Spine3, BoneRole.Spine2, BoneRole.Spine1, BoneRole.Spine0,
        })
        {
            if (map.RoleToBone.TryGetValue(role, out var bone))
                return bone;
        }
        return null;
    }

    private static MappingReportInfo BuildReport(
        MappingResult map, bool needsUserDecision, SkeletonModel skeleton)
    {
        var report = new MappingReportInfo
        {
            ProfileName = map.ProfileName,
            Source = map.Source,
            Confidence = map.Confidence,
            NeedsUserDecision = needsUserDecision,
            MappedRoleCount = map.RoleToBone.Count,
            SkeletonSignature = Mapping.SkeletonSignature.Compute(skeleton),
        };
        report.Notes.AddRange(map.Notes);
        if (needsUserDecision)
            report.Notes.Add(
                $"No preset profile matched and auto-map confidence {map.Confidence:0.00} is below "
                + $"{ProfileDetector.DetectionThreshold:0.00}; proceeding with the best-effort auto map.");
        return report;
    }

    // ================================================================ naming

    private static string RequestedClipName(RetargetRequest request, SourceScene scene, int take)
    {
        var multipleTakes = scene.Clips.Count > 1;
        if (!string.IsNullOrWhiteSpace(request.ClipNameOverride))
            return multipleTakes ? $"{request.ClipNameOverride}_{take + 1}" : request.ClipNameOverride!;

        var takeName = scene.Clips[take].Name;
        if (!string.IsNullOrWhiteSpace(takeName) && takeName != "motion"
            && !takeName.Equals("mixamo.com", StringComparison.OrdinalIgnoreCase))
            return takeName;

        // BVH files (clip always named "motion"), Mixamo takes (always named
        // "mixamo.com") and unnamed takes use the file stem.
        var stem = FileStem(request.SourceFileName);
        return multipleTakes ? $"{stem}_{take + 1}" : stem;
    }

    /// <summary>Collision auto-suffixing across the batch: <c>name</c>, <c>name_2</c>, …
    /// (case-insensitive — the names also become DMX file names).</summary>
    private static string UniqueClipName(string name, HashSet<string> usedNames, bool autoSuffix)
    {
        if (usedNames.Add(name) || !autoSuffix)
            return name;
        for (var i = 2; ; i++)
        {
            var candidate = $"{name}_{i}";
            if (usedNames.Add(candidate))
                return candidate;
        }
    }

    /// <summary>
    /// Clip names become ModelDoc AnimFile node names and Source 2 sequence names, which
    /// must stay within <c>[A-Za-z0-9_]</c> — a take name like <c>mixamo.com</c> otherwise
    /// fails the vmdl compile with "Node 'mixamo.com' resolve failure". Runs of invalid
    /// characters collapse to a single underscore; case is preserved. Public so UIs that
    /// dry-run name-based detection (e.g. the locomotion-set scan over raw take names) see
    /// the SAME spelling conversion will produce — a take named <c>Walk N</c> becomes the
    /// clip <c>Walk_N</c>.
    /// </summary>
    public static string SanitizeClipName(string name)
    {
        var builder = new StringBuilder(name.Length);
        var lastWasUnderscore = false;
        foreach (var c in name)
        {
            if (c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '_')
            {
                builder.Append(c);
                lastWasUnderscore = c == '_';
            }
            else if (!lastWasUnderscore)
            {
                builder.Append('_');
                lastWasUnderscore = true;
            }
        }
        var sanitized = builder.ToString().Trim('_');
        return sanitized.Length > 0 ? sanitized : "clip";
    }

    private static string FileStem(string fileName)
    {
        var start = Math.Max(fileName.LastIndexOf('/'), fileName.LastIndexOf('\\')) + 1;
        var dot = fileName.LastIndexOf('.');
        var end = dot > start ? dot : fileName.Length;
        var stem = fileName.Substring(start, end - start);
        return stem.Length > 0 ? stem : "clip";
    }

    private static string ExtensionOf(string fileName)
    {
        var dot = fileName.LastIndexOf('.');
        if (dot < 0 || dot == fileName.Length - 1)
            return "";
        var ext = fileName.Substring(dot + 1);
        return ext.IndexOfAny(new[] { '/', '\\' }) >= 0 ? "" : ext.ToLowerInvariant();
    }

    private static string SanitizeFileName(string name)
    {
        var builder = new StringBuilder(name.Length);
        foreach (var c in name)
        {
            builder.Append(
                c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_' or '-' ? c
                : c is >= 'A' and <= 'Z' ? char.ToLowerInvariant(c)
                : '_');
        }
        var sanitized = builder.ToString().Trim('_');
        return sanitized.Length > 0 ? sanitized : "clip";
    }

    private static string JoinAssetPath(string folder, string file)
    {
        var f = folder.Replace('\\', '/').TrimEnd('/');
        return f.Length == 0 ? file : f + "/" + file;
    }

    // ================================================================ target context

    /// <summary>Everything derived once per batch from the target rig: character up axis,
    /// foot chains, hips/root indices, IK-bone presence.</summary>
    internal sealed class TargetContext
    {
        public TargetRig Rig { get; }

        /// <summary>Target character up direction (midShoulders − midHips on the rest pose,
        /// via <see cref="CharacterFrame"/>); null when the rig lacks the bones for it.</summary>
        public Vector3? Up { get; }

        /// <summary>Target character frame (for the arm-IK basis change); null when not computable.</summary>
        public CharacterFrame? Frame { get; }

        /// <summary>Left/right leg chains from the rig's UpperLeg/LowerLeg/Foot/Toe roles;
        /// null when either leg is incompletely mapped.</summary>
        public (FootChain Left, FootChain Right)? FootChains { get; }

        /// <summary>Why <see cref="Up"/> / <see cref="FootChains"/> are unavailable (report note text).</summary>
        public string UpOrChainProblem { get; } = "";

        /// <summary>Target bone carrying the Hips role.</summary>
        public int? HipsIndex { get; }

        /// <summary>Dedicated translating root: a parentless Animated bone that is an
        /// ancestor of (and distinct from) the hips. Null on the s&amp;box rig (pelvis is
        /// itself parentless; root_IK is IkBaked).</summary>
        public int? DedicatedRootIndex { get; }

        /// <summary>True when the hips bone's direct parent is the dedicated root.</summary>
        public bool HipsParentIsRoot { get; }

        public bool HasIkBakedBones { get; }

        /// <summary>ConstraintDriven bone indices (twist/helper) — excluded from DMX
        /// channels per design §3; null when the rig has none. Face bones
        /// (<see cref="SboxBoneClassifier.IsFaceBone"/>) are NOT in this set even though
        /// their class is ConstraintDriven: they keep rest-local channels, because no
        /// constraint re-drives them in a compiled sequence (channel-less face joints
        /// bake statically — eyes detach from the moving head in ModelDoc).</summary>
        public IReadOnlySet<int>? ConstraintDrivenBones { get; }

        private Dl.DlSolver? _dlSolver;

        /// <summary>The batch-shared deep-learning solver, parsing <paramref name="weights"/>
        /// once on first use (the batch runs requests sequentially — no locking needed).</summary>
        public IRetargetSolver GetDlSolver(byte[] weights)
            => _dlSolver ??= new Dl.DlSolver(weights);

        public TargetContext(TargetRig rig)
        {
            Rig = rig;
            HipsIndex = rig.BoneForRole(BoneRole.Hips);
            DedicatedRootIndex = FindDedicatedRoot(rig, HipsIndex);
            HipsParentIsRoot = HipsIndex is { } hips && DedicatedRootIndex is { } root
                && rig.Skeleton[hips].ParentIndex == root;
            foreach (var _ in rig.BonesOfClass(BoneClass.IkBaked))
            {
                HasIkBakedBones = true;
                break;
            }

            var constraintDriven = new HashSet<int>(rig.BonesOfClass(BoneClass.ConstraintDriven));
            // Face bones (eye_/ear_/face_) are ConstraintDriven by class but, unlike the
            // twist/helper bones, nothing re-drives them when a compiled sequence plays:
            // the model's AnimConstraintList never references them and the engine's eye
            // look-at / blinking only runs in game. They must KEEP their rest-local DMX
            // channels (the shipped fbx2dmx clips carry them too) or ModelDoc bakes the
            // channel-less joints statically and the eyes detach from the moving head
            // (see SboxBoneClassifier.IsFaceBone).
            constraintDriven.RemoveWhere(i => SboxBoneClassifier.IsFaceBone(rig.Skeleton[i].Name));
            ConstraintDrivenBones = constraintDriven.Count > 0 ? constraintDriven : null;

            try
            {
                Frame = CharacterFrame.Compute(rig.Skeleton, rig.ToMappingResult(), rig.Skeleton.RestWorld);
                Up = Frame.Up;
            }
            catch (ArgumentException e)
            {
                UpOrChainProblem = $"target character frame not computable ({e.Message})";
                return;
            }

            var left = LegChain(rig, BoneRole.UpperLegL, BoneRole.LowerLegL, BoneRole.FootL, BoneRole.ToeL);
            var right = LegChain(rig, BoneRole.UpperLegR, BoneRole.LowerLegR, BoneRole.FootR, BoneRole.ToeR);
            if (left is not null && right is not null)
                FootChains = (left, right);
            else
                UpOrChainProblem = "target rig does not map a complete UpperLeg/LowerLeg/Foot chain on both sides";
        }

        private static FootChain? LegChain(TargetRig rig, BoneRole upper, BoneRole lower, BoneRole foot, BoneRole toe)
            => rig.BoneForRole(upper) is { } hip
               && rig.BoneForRole(lower) is { } knee
               && rig.BoneForRole(foot) is { } ankle
                ? new FootChain { Hip = hip, Knee = knee, Ankle = ankle, Toe = rig.BoneForRole(toe) }
                : null;

        private static int? FindDedicatedRoot(TargetRig rig, int? hipsIndex)
        {
            if (hipsIndex is not { } hips)
                return null;
            // Walk up from the hips; the topmost ancestor qualifies when it is a parentless
            // Animated bone distinct from the hips itself.
            var top = hips;
            while (rig.Skeleton[top].ParentIndex >= 0)
                top = rig.Skeleton[top].ParentIndex;
            return top != hips && rig.ClassOf(top) == BoneClass.Animated ? top : null;
        }
    }

    // ================================================================ arm effector IK

    /// <summary>
    /// Optional wrist-reach cleanup: per frame, the source hand offset from its shoulder is
    /// limb-length-normalized, re-expressed in the target's character basis, and used as a
    /// two-bone IK goal for the target arm (<see cref="EffectorIk.ApplyGoals"/>).
    /// </summary>
    private static class ArmIkCleanup
    {
        /// <summary>Runs both arms; returns a problem note (and does nothing) when the
        /// prerequisites are missing, null on success.</summary>
        public static string? Apply(
            List<XForm[]> frames, SourceScene scene, MappingResult map,
            TargetRig rig, TargetContext context, int take)
        {
            if (context.Frame is not { } targetFrame)
                return context.UpOrChainProblem;

            CharacterFrame sourceFrame;
            try
            {
                sourceFrame = CharacterFrame.Compute(scene.Skeleton, map, scene.Skeleton.RestWorld);
            }
            catch (ArgumentException e)
            {
                return $"source character frame not computable ({e.Message})";
            }

            // Change of basis source space → target space (this library's convention:
            // a * b applies b first).
            var sourceToTarget = MathQ.Normalize(
                BasisRotation(targetFrame) * Quaternion.Conjugate(BasisRotation(sourceFrame)));

            var appliedAny = false;
            appliedAny |= ApplyArm(frames, scene, map, rig, sourceToTarget, take,
                BoneRole.UpperArmL, BoneRole.LowerArmL, BoneRole.HandL);
            appliedAny |= ApplyArm(frames, scene, map, rig, sourceToTarget, take,
                BoneRole.UpperArmR, BoneRole.LowerArmR, BoneRole.HandR);
            return appliedAny ? null : "no complete UpperArm/LowerArm/Hand chain mapped on source and target";
        }

        private static bool ApplyArm(
            List<XForm[]> frames, SourceScene scene, MappingResult map, TargetRig rig,
            Quaternion sourceToTarget, int take, BoneRole upperRole, BoneRole lowerRole, BoneRole handRole)
        {
            if (!map.RoleToBone.TryGetValue(upperRole, out var srcUpper)
                || !map.RoleToBone.TryGetValue(lowerRole, out var srcLower)
                || !map.RoleToBone.TryGetValue(handRole, out var srcHand))
                return false;
            if (rig.BoneForRole(upperRole) is not { } tgtUpper
                || rig.BoneForRole(lowerRole) is not { } tgtLower
                || rig.BoneForRole(handRole) is not { } tgtHand)
                return false;

            var src = scene.Skeleton;
            var tgt = rig.Skeleton;
            var srcLen = Vector3.Distance(src.RestWorld[srcUpper].Pos, src.RestWorld[srcLower].Pos)
                       + Vector3.Distance(src.RestWorld[srcLower].Pos, src.RestWorld[srcHand].Pos);
            var tgtLen = Vector3.Distance(tgt.RestWorld[tgtUpper].Pos, tgt.RestWorld[tgtLower].Pos)
                       + Vector3.Distance(tgt.RestWorld[tgtLower].Pos, tgt.RestWorld[tgtHand].Pos);
            if (srcLen < 1e-4f || tgtLen < 1e-4f)
                return false;
            var scale = tgtLen / srcLen;

            var sourceFrames = scene.Clips[take].Frames;
            var count = Math.Min(frames.Count, sourceFrames.Count);
            var goals = new Vector3[frames.Count];
            for (var f = 0; f < frames.Count; f++)
            {
                var srcWorld = new Pose(sourceFrames[Math.Min(f, count - 1)]).ToWorld(src);
                var tgtWorld = new Pose(frames[f]).ToWorld(tgt);
                var reach = srcWorld[srcHand].Pos - srcWorld[srcUpper].Pos;
                goals[f] = tgtWorld[tgtUpper].Pos + Vector3.Transform(reach, sourceToTarget) * scale;
            }

            EffectorIk.ApplyGoals(
                frames, tgt,
                new LimbChain { Upper = tgtUpper, Lower = tgtLower, End = tgtHand },
                goals, RestBendAxis(tgt, tgtUpper, tgtLower, tgtHand));
            return true;
        }

        /// <summary>Hinge-axis fallback from the rest bend plane (elbow), like the foot pass.</summary>
        private static Vector3 RestBendAxis(SkeletonModel skeleton, int upper, int lower, int end)
        {
            var a = skeleton.RestWorld[upper].Pos;
            var b = skeleton.RestWorld[lower].Pos;
            var c = skeleton.RestWorld[end].Pos;
            var axis = Vector3.Cross(c - a, b - a);
            if (axis.LengthSquared() > 1e-6f)
                return Vector3.Normalize(axis);
            var limb = c - a;
            axis = Vector3.Cross(limb, Vector3.UnitY);
            if (axis.LengthSquared() < 1e-6f)
                axis = Vector3.Cross(limb, Vector3.UnitZ);
            return axis.LengthSquared() > 1e-12f ? Vector3.Normalize(axis) : Vector3.UnitX;
        }

        /// <summary>Rotation taking canonical character axes (X=lateral, Y=up, Z=forward)
        /// to the rig's world axes.</summary>
        private static Quaternion BasisRotation(CharacterFrame frame)
        {
            var m = new Matrix4x4(
                frame.Lateral.X, frame.Lateral.Y, frame.Lateral.Z, 0f,
                frame.Up.X, frame.Up.Y, frame.Up.Z, 0f,
                frame.Forward.X, frame.Forward.Y, frame.Forward.Z, 0f,
                0f, 0f, 0f, 1f);
            return MathQ.Normalize(Quaternion.CreateFromRotationMatrix(m));
        }
    }
}