Editor/WeaponImporter/Core/Graph/WeaponGraphGenerator.cs
#nullable enable annotations

using WeaponImporter.Core.Analysis;

namespace WeaponImporter.Core.Graph;

/// <summary>One weapon clip the graph can play: its role, compiled sequence name, looping and length.</summary>
public sealed record GraphClip(AnimationRole Role, string Sequence, bool Loop, float Seconds);

/// <summary>Parameter names of generated weapon graphs (Facepunch first-person conventions where they exist).</summary>
public static class WeaponParams
{
    public const string Attack = "b_attack";
    public const string AttackDry = "b_attack_dry";
    public const string Empty = "b_empty";
    public const string Reload = "b_reload";
    public const string DeploySkip = "b_deploy_skip";
    public const string Holster = "b_holster";
    public const string Inspect = "b_inspect";
    public const string Deploy = "b_deploy";
    public const string Sprint = "b_sprint";
    public const string MoveBob = "move_bob";
    public const string Ironsights = "ironsights";
    public const string FireMode = "b_firemode";
    public const string Pump = "b_pump";
    public const string Melee = "b_melee";
    public const string Jump = "b_jump";
    public const string SpeedReload = "speed_reload";
    public const string SpeedDeploy = "speed_deploy";
    public const string SpeedIronsights = "speed_ironsights";

    /// <summary>Event tag names (graph tags, dispatched to OnAnimTagEvent).</summary>
    public const string TagHolsterFinished = "holster_finished";
    public const string TagReloadIncrement = "reload_increment";
    public const string TagAttackDiscouraged = "attack_discouraged";
}

/// <summary>What the generated graph supports, for the review screen and docs.</summary>
public sealed record GraphSummary(IReadOnlyList<string> States, IReadOnlyList<string> Parameters, IReadOnlyList<string> Tags);

/// <summary>
/// Generates a first-person weapon AnimGraph: one state machine whose states exist only for the
/// clips the weapon actually has, driven by the Facepunch parameter names (b_attack, b_reload,
/// b_empty, b_holster, b_deploy_skip, b_sprint, ironsights) so existing first-person weapon code
/// works unchanged. Ported from the original importer's DefaultGraphGenerator; the graph logic
/// is unchanged, only the input is now a flat clip list.
/// </summary>
public static class WeaponGraphGenerator
{
    private const float QuickBlend = 0.05f;
    private const float Blend = 0.15f;
    private const float LocomotionBlend = 0.25f;

    /// <summary>Default cycles when the clip carries no holster_complete / shell_insert event.</summary>
    private const float HolsterFinishCycle = 0.9f;
    private const float InsertCycle = 0.55f;

    /// <summary>
    /// The original importer's role set. v3 roles map onto a subset of these (see <see cref="SlotFor"/>);
    /// the shell-reload / fire-mode / jump branches are kept so the graph logic stays identical.
    /// </summary>
    private enum Slot
    {
        Idle, Fire, FireLast, FireAds, DryFire, Reload, ReloadEmpty, ReloadStart, ReloadInsert, ReloadEnd,
        Deploy, Holster, Inspect, Walk, Sprint, AdsIn, AdsIdle, AdsOut, FireMode, Pump, Bolt, Melee, Jump,
    }

    private static Slot? SlotFor(AnimationRole role) => role switch
    {
        AnimationRole.Idle => Slot.Idle,
        AnimationRole.Fire => Slot.Fire,
        AnimationRole.FireEmpty => Slot.FireLast,       // attack while empty (b_attack + b_empty)
        AnimationRole.Reload => Slot.Reload,
        AnimationRole.TacticalReload => Slot.Reload,    // used only when there is no plain reload
        AnimationRole.EmptyReload => Slot.ReloadEmpty,
        AnimationRole.Draw => Slot.Deploy,
        AnimationRole.Holster => Slot.Holster,
        AnimationRole.Inspect => Slot.Inspect,
        AnimationRole.Sprint => Slot.Sprint,
        AnimationRole.Walk => Slot.Walk,
        // ADS raises the sights: the graph's "Aim" state plays it and holds its last frame.
        AnimationRole.Ads => Slot.AdsIn,
        AnimationRole.AdsOut => Slot.AdsOut,
        AnimationRole.AdsIdle => Slot.AdsIdle,
        AnimationRole.ReloadStart => Slot.ReloadStart,
        AnimationRole.ReloadInsert => Slot.ReloadInsert,
        AnimationRole.ReloadEnd => Slot.ReloadEnd,
        AnimationRole.AdsFire => Slot.FireAds,
        AnimationRole.Melee => Slot.Melee,
        AnimationRole.Bolt => Slot.Bolt,
        _ => null,                                      // Jam / Unjam / Unknown: no graph state
    };

    /// <summary>
    /// Builds the graph text. Throws when there is no idle clip (the graph is built around it).
    /// <paramref name="cameraBone"/> is the engine bone name the editor preview camera locks to;
    /// <paramref name="previewModel"/> is the vmdl shown in the AnimGraph editor.
    /// <paramref name="fireCyclesAction"/>: when false, the bolt clip plays after every shot (bolt-action);
    /// when true (default) it only plays on b_pump, since a v3 "Bolt / Charge" clip is often a charging handle.
    /// </summary>
    public static string Generate(string weaponName, IReadOnlyList<GraphClip> clips, string? cameraBone, out GraphSummary summary,
        string? previewModel = null, bool fireCyclesAction = true)
    {
        if (clips is null)
            throw new ArgumentNullException(nameof(clips));

        // First clip per slot wins; a plain reload beats a tactical one.
        var bySlot = new Dictionary<Slot, GraphClip>();
        foreach (var clip in clips.OrderBy(c => c?.Role ==AnimationRole.TacticalReload ? 1 : 0))
        {
            if (clip is null || string.IsNullOrEmpty(clip.Sequence) || SlotFor(clip.Role) is not { } slot)
                continue;
            // Locomotion (walk, sprint) stays out of the weapon graph, as in Facepunch's own
            // first-person weapon graphs: sway and bob belong to the game's code. The clips are
            // still in the model for games that want to play them.
            if (clip.Role is AnimationRole.Walk or AnimationRole.Sprint)
                continue;
            bySlot.TryAdd(slot, clip);
        }

        var graph = new AnimGraphBuilder(weaponName);
        var sm = new StateMachineBuilder(graph);
        var states = new List<string>();

        bool Has(Slot role) => bySlot.ContainsKey(role);
        GraphClip Seq(Slot role) => bySlot[role];

        if (!Has(Slot.Idle))
            throw new InvalidOperationException("The weapon has no idle animation to build the graph around.");

        // ---------------------------------------------------------------- tags
        var busy = graph.Tag("Busy", eventTag: false);
        var aiming = Has(Slot.AdsIdle) || Has(Slot.AdsIn) ? graph.Tag("Aiming", eventTag: false) : 0;
        var holstering = Has(Slot.Holster) ? graph.Tag("Holstering", eventTag: false) : 0;
        var discouraged = graph.Tag(WeaponParams.TagAttackDiscouraged, eventTag: true);
        var holsterFinished = Has(Slot.Holster) ? graph.Tag(WeaponParams.TagHolsterFinished, eventTag: true) : 0;
        var shellReload = Has(Slot.ReloadInsert);
        var reloadIncrement = shellReload ? graph.Tag(WeaponParams.TagReloadIncrement, eventTag: true) : 0;

        // ---------------------------------------------------------------- params
        var pAttack = Has(Slot.Fire) || Has(Slot.FireAds) || Has(Slot.Melee) && !Has(Slot.Fire)
            ? graph.BoolParam(WeaponParams.Attack, autoReset: true) : 0;
        var pDry = Has(Slot.DryFire) ? graph.BoolParam(WeaponParams.AttackDry, autoReset: true) : 0;
        var pEmpty = Has(Slot.ReloadEmpty) || Has(Slot.FireLast) ? graph.BoolParam(WeaponParams.Empty, autoReset: false) : 0;
        var hasReload = Has(Slot.Reload) || Has(Slot.ReloadEmpty) || shellReload;
        // Shell-by-shell reloads keep b_reload held (Facepunch shotgun convention); magazine reloads auto-reset.
        var pReload = hasReload ? graph.BoolParam(WeaponParams.Reload, autoReset: !shellReload) : 0;
        var pSpeedReload = hasReload ? graph.FloatParam(WeaponParams.SpeedReload, 0.1f, 4f, 1f) : 0;
        var pSkip = Has(Slot.Deploy) ? graph.BoolParam(WeaponParams.DeploySkip, autoReset: false) : 0;
        var pSpeedDeploy = Has(Slot.Deploy) ? graph.FloatParam(WeaponParams.SpeedDeploy, 0.1f, 4f, 1f) : 0;
        var pHolster = Has(Slot.Holster) ? graph.BoolParam(WeaponParams.Holster, autoReset: false) : 0;
        var pInspect = Has(Slot.Inspect) ? graph.BoolParam(WeaponParams.Inspect, autoReset: true) : 0;
        var pSprint = Has(Slot.Sprint) ? graph.BoolParam(WeaponParams.Sprint, autoReset: false) : 0;
        var pBob = Has(Slot.Walk) ? graph.FloatParam(WeaponParams.MoveBob, 0f, 1f, 0f) : 0;
        var hasAds = Has(Slot.AdsIdle) || Has(Slot.AdsIn);
        var pIron = hasAds ? graph.EnumParam(WeaponParams.Ironsights, new[] { "None", "Normal" }) : 0;
        var pSpeedIron = hasAds ? graph.FloatParam(WeaponParams.SpeedIronsights, 0.1f, 4f, 1f) : 0;
        var pMode = Has(Slot.FireMode) ? graph.BoolParam(WeaponParams.FireMode, autoReset: true) : 0;
        var pPump = Has(Slot.Pump) || Has(Slot.Bolt) ? graph.BoolParam(WeaponParams.Pump, autoReset: true) : 0;
        var pMelee = Has(Slot.Melee) && Has(Slot.Fire) ? graph.BoolParam(WeaponParams.Melee, autoReset: true) : 0;
        var pJump = Has(Slot.Jump) ? graph.BoolParam(WeaponParams.Jump, autoReset: true) : 0;

        // ---------------------------------------------------------------- nodes / states
        float y = 0;
        long Node(Slot role, long speedParam = 0, params (long Tag, float Start, float Duration)[] spans)
        {
            var sequence = Seq(role);
            var node = graph.Sequence(sequence.Sequence, sequence.Sequence, sequence.Loop, -600, y, 1f, spans);
            y += 96;
            return speedParam != 0 ? graph.SpeedScale(sequence.Sequence + " speed", node, speedParam, -380, y - 96) : node;
        }

        float column = 0;
        StateMachineBuilder.State State(string name, long input, bool start = false, params long[] tags)
        {
            var state = sm.Add(name, input, 200 + (column % 4) * 220, 80 + (float)Math.Floor(column / 4) * 140, start);
            column++;
            state.Tags.AddRange(tags.Where(t => t != 0));
            states.Add(name);
            return state;
        }

        var idle = State("Idle", Node(Slot.Idle), start: !Has(Slot.Deploy));
        var any = sm.Add("Any", null, 40, -80, start: false, alwaysEvaluate: true);

        StateMachineBuilder.State? deploy = null;
        if (Has(Slot.Deploy))
        {
            deploy = State("Deploy", Node(Slot.Deploy, pSpeedDeploy, (discouraged, 0f, 0.9f)), start: true, busy);
            sm.Transition(deploy, idle, Blend, false, StateMachineBuilder.Finished());
            sm.Transition(deploy, idle, QuickBlend, false, StateMachineBuilder.Bool(pSkip, true));
            // b_deploy plays the draw again (switching back to this weapon).
            var pDeploy = graph.BoolParam(WeaponParams.Deploy, autoReset: true);
            sm.Transition(any, deploy, 0f, true, StateMachineBuilder.Bool(pDeploy, true));
        }

        if (Has(Slot.Holster))
        {
            var holster = State("Holster", Node(Slot.Holster, 0, (holsterFinished, HolsterFinishCycle, 0.02f), (discouraged, 0f, 1f)), false, busy, holstering);
            sm.Transition(any, holster, Blend, true, StateMachineBuilder.Bool(pHolster, true), StateMachineBuilder.TagActive(holstering, false));
            sm.Transition(holster, deploy ?? idle, Blend, true, StateMachineBuilder.Bool(pHolster, false));
        }

        // Aiming first: while aimed, attack plays the aimed fire.
        StateMachineBuilder.State? adsIdle = null;
        if (hasAds)
        {
            var adsIdleRole = Has(Slot.AdsIdle) ? Slot.AdsIdle : Slot.AdsIn;
            adsIdle = State("Aim", Node(adsIdleRole == Slot.AdsIn ? Slot.AdsIn : Slot.AdsIdle), false, aiming);
            if (Has(Slot.AdsIn) && adsIdleRole == Slot.AdsIdle)
            {
                var aimIn = State("Aim In", Node(Slot.AdsIn, pSpeedIron), false, aiming);
                sm.Transition(idle, aimIn, QuickBlend, true, StateMachineBuilder.Enum(pIron, CompareOp.NotEqual, 0));
                sm.Transition(aimIn, adsIdle, QuickBlend, false, StateMachineBuilder.Finished());
                sm.Transition(aimIn, idle, Blend, false, StateMachineBuilder.Enum(pIron, CompareOp.Equal, 0));
            }
            else
            {
                sm.Transition(idle, adsIdle, Blend, true, StateMachineBuilder.Enum(pIron, CompareOp.NotEqual, 0));
            }
            if (Has(Slot.AdsOut))
            {
                var aimOut = State("Aim Out", Node(Slot.AdsOut, pSpeedIron));
                sm.Transition(adsIdle, aimOut, QuickBlend, true, StateMachineBuilder.Enum(pIron, CompareOp.Equal, 0));
                sm.Transition(aimOut, idle, Blend, false, StateMachineBuilder.Finished());
                sm.Transition(aimOut, adsIdle, QuickBlend, true, StateMachineBuilder.Enum(pIron, CompareOp.NotEqual, 0));
            }
            else
            {
                sm.Transition(adsIdle, idle, Blend, false, StateMachineBuilder.Enum(pIron, CompareOp.Equal, 0));
            }

            if (Has(Slot.FireAds) && pAttack != 0)
            {
                var fireAds = State("Fire Aimed", Node(Slot.FireAds), false, aiming);
                sm.Transition(any, fireAds, 0f, true, StateMachineBuilder.Bool(pAttack, true), StateMachineBuilder.TagActive(aiming, true), StateMachineBuilder.TagActive(busy, false));
                sm.Transition(fireAds, adsIdle, Blend, false, StateMachineBuilder.Finished());
            }
        }

        // Reloads before fire so a reload request wins over a held trigger.
        if (hasReload && !shellReload)
        {
            if (Has(Slot.ReloadEmpty))
            {
                var empty = State("Reload Empty", Node(Slot.ReloadEmpty, pSpeedReload, (discouraged, 0f, 0.9f)), false, busy);
                sm.Transition(any, empty, Blend, true, StateMachineBuilder.Bool(pReload, true), StateMachineBuilder.Bool(pEmpty, true), StateMachineBuilder.TagActive(busy, false));
                sm.Transition(empty, idle, Blend, false, StateMachineBuilder.Finished());
            }
            var reloadRole = Has(Slot.Reload) ? Slot.Reload : Slot.ReloadEmpty;
            var reload = State("Reload", Node(reloadRole, pSpeedReload, (discouraged, 0f, 0.9f)), false, busy);
            sm.Transition(any, reload, Blend, true, StateMachineBuilder.Bool(pReload, true), StateMachineBuilder.TagActive(busy, false));
            sm.Transition(reload, idle, Blend, false, StateMachineBuilder.Finished());
        }
        else if (shellReload)
        {
            var insertSeq = Seq(Slot.ReloadInsert);
            StateMachineBuilder.State? start = Has(Slot.ReloadStart)
                ? State("Reload Start", Node(Slot.ReloadStart, pSpeedReload), false, busy) : null;
            // Two insert states ping-pong so every shell restarts the insert animation.
            var insertA = State("Reload Insert A", graph.Sequence("reload_insert_a", insertSeq.Sequence, false, -600, y, 1f, new[] { (reloadIncrement, InsertCycle, 0.02f) }), false, busy);
            y += 96;
            var insertB = State("Reload Insert B", graph.Sequence("reload_insert_b", insertSeq.Sequence, false, -600, y, 1f, new[] { (reloadIncrement, InsertCycle, 0.02f) }), false, busy);
            y += 96;
            StateMachineBuilder.State? end = Has(Slot.ReloadEnd)
                ? State("Reload End", Node(Slot.ReloadEnd, pSpeedReload), false, busy) : null;

            var entry = start ?? insertA;
            sm.Transition(any, entry, Blend, true, StateMachineBuilder.Bool(pReload, true), StateMachineBuilder.TagActive(busy, false));
            if (start is not null)
            {
                sm.Transition(start, insertA, QuickBlend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pReload, true));
                sm.Transition(start, end ?? idle, Blend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pReload, false));
            }
            sm.Transition(insertA, insertB, QuickBlend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pReload, true));
            sm.Transition(insertB, insertA, QuickBlend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pReload, true));
            sm.Transition(insertA, end ?? idle, Blend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pReload, false));
            sm.Transition(insertB, end ?? idle, Blend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pReload, false));
            if (end is not null)
                sm.Transition(end, idle, Blend, false, StateMachineBuilder.Finished());
            if (Has(Slot.Reload) || Has(Slot.ReloadEmpty))
            {
                // A full-magazine style reload also exists: play it when the gun is empty.
                var fullRole = Has(Slot.ReloadEmpty) ? Slot.ReloadEmpty : Slot.Reload;
                if (pEmpty != 0)
                {
                    var full = State("Reload Full", Node(fullRole, pSpeedReload), false, busy);
                    sm.Transition(any, full, Blend, true, StateMachineBuilder.Bool(pReload, true), StateMachineBuilder.Bool(pEmpty, true), StateMachineBuilder.TagActive(busy, false));
                    sm.Transition(full, idle, Blend, false, StateMachineBuilder.Finished());
                }
            }
        }

        if (pDry != 0)
        {
            var dry = State("Dry Fire", Node(Slot.DryFire));
            sm.Transition(any, dry, 0f, true, StateMachineBuilder.Bool(pDry, true), StateMachineBuilder.TagActive(busy, false));
            sm.Transition(dry, idle, Blend, false, StateMachineBuilder.Finished());
        }

        if (Has(Slot.Fire) && pAttack != 0)
        {
            // Aimed without an aimed-fire clip: a hip-fire clip would drop the sights, so shots
            // keep the aimed pose (the viewmodel adds its own kick). Hip fire only when not aimed.
            var hipOnly = adsIdle is not null && !Has(Slot.FireAds);
            var fire = State("Fire", Node(Slot.Fire));
            if (Has(Slot.FireLast) && pEmpty != 0)
            {
                var last = State("Fire Last", Node(Slot.FireLast));
                if (hipOnly)
                    sm.Transition(any, last, 0f, true, StateMachineBuilder.Bool(pAttack, true), StateMachineBuilder.Bool(pEmpty, true), StateMachineBuilder.TagActive(busy, false), StateMachineBuilder.TagActive(aiming, false));
                else
                    sm.Transition(any, last, 0f, true, StateMachineBuilder.Bool(pAttack, true), StateMachineBuilder.Bool(pEmpty, true), StateMachineBuilder.TagActive(busy, false));
                sm.Transition(last, idle, Blend, false, StateMachineBuilder.Finished());
            }
            if (hipOnly)
                sm.Transition(any, fire, 0f, true, StateMachineBuilder.Bool(pAttack, true), StateMachineBuilder.TagActive(busy, false), StateMachineBuilder.TagActive(aiming, false));
            else
                sm.Transition(any, fire, 0f, true, StateMachineBuilder.Bool(pAttack, true), StateMachineBuilder.TagActive(busy, false));

            // Bolt/pump actions chain after the shot when the fire clip does not cycle the action itself.
            var actionRole = Has(Slot.Pump) ? Slot.Pump : Has(Slot.Bolt) ? Slot.Bolt : (Slot?)null;
            if (actionRole is { } role)
            {
                var action = State(role == Slot.Pump ? "Pump" : "Bolt", Node(role), false, busy);
                sm.Transition(any, action, Blend, true, StateMachineBuilder.Bool(pPump, true), StateMachineBuilder.TagActive(busy, false));
                sm.Transition(action, idle, Blend, false, StateMachineBuilder.Finished());
                if (!fireCyclesAction)
                {
                    if (pEmpty != 0)
                        sm.Transition(fire, action, QuickBlend, true, StateMachineBuilder.Finished(), StateMachineBuilder.Bool(pEmpty, false));
                    else
                        sm.Transition(fire, action, QuickBlend, true, StateMachineBuilder.Finished());
                }
            }
            if (adsIdle is not null)
                sm.Transition(fire, adsIdle, Blend, false, StateMachineBuilder.Finished(), StateMachineBuilder.Enum(pIron, CompareOp.NotEqual, 0));
            sm.Transition(fire, idle, Blend, false, StateMachineBuilder.Finished());
        }
        else if (Has(Slot.Melee) && pAttack != 0)
        {
            var swing = State("Melee", Node(Slot.Melee));
            sm.Transition(any, swing, 0f, true, StateMachineBuilder.Bool(pAttack, true), StateMachineBuilder.TagActive(busy, false));
            sm.Transition(swing, idle, Blend, false, StateMachineBuilder.Finished());
        }

        if (pMelee != 0)
        {
            var melee = State("Melee", Node(Slot.Melee), false, busy);
            sm.Transition(any, melee, QuickBlend, true, StateMachineBuilder.Bool(pMelee, true), StateMachineBuilder.TagActive(busy, false));
            sm.Transition(melee, idle, Blend, false, StateMachineBuilder.Finished());
        }

        if (pInspect != 0)
        {
            var inspect = State("Inspect", Node(Slot.Inspect), false, busy);
            sm.Transition(any, inspect, Blend, true, StateMachineBuilder.Bool(pInspect, true), StateMachineBuilder.TagActive(busy, false));
            sm.Transition(inspect, idle, Blend, false, StateMachineBuilder.Finished());
            // Inspecting is cancelled by any action.
            if (pAttack != 0) sm.Transition(inspect, idle, QuickBlend, false, StateMachineBuilder.Bool(pAttack, true));
        }

        if (pMode != 0)
        {
            var mode = State("Fire Mode", Node(Slot.FireMode), false, busy);
            sm.Transition(any, mode, Blend, true, StateMachineBuilder.Bool(pMode, true), StateMachineBuilder.TagActive(busy, false));
            sm.Transition(mode, idle, Blend, false, StateMachineBuilder.Finished());
        }

        if (pJump != 0)
        {
            var jump = State("Jump", Node(Slot.Jump));
            sm.Transition(idle, jump, QuickBlend, true, StateMachineBuilder.Bool(pJump, true));
            sm.Transition(jump, idle, Blend, false, StateMachineBuilder.Finished());
        }

        // Locomotion: walk on move_bob, sprint on b_sprint.
        StateMachineBuilder.State? walk = null;
        if (pBob != 0)
        {
            walk = State("Walk", Node(Slot.Walk));
            sm.Transition(idle, walk, LocomotionBlend, false, StateMachineBuilder.Float(pBob, CompareOp.Greater, 0.1f));
            sm.Transition(walk, idle, LocomotionBlend, false, StateMachineBuilder.Float(pBob, CompareOp.LessOrEqual, 0.1f));
            if (adsIdle is not null)
                sm.Transition(walk, idle, QuickBlend, false, StateMachineBuilder.Enum(pIron, CompareOp.NotEqual, 0));
        }
        if (pSprint != 0)
        {
            var sprint = State("Sprint", Node(Slot.Sprint), false, discouraged);
            sm.Transition(idle, sprint, LocomotionBlend, false, StateMachineBuilder.Bool(pSprint, true));
            if (walk is not null)
                sm.Transition(walk, sprint, LocomotionBlend, false, StateMachineBuilder.Bool(pSprint, true));
            if (adsIdle is not null)
                sm.Transition(adsIdle, sprint, LocomotionBlend, false, StateMachineBuilder.Bool(pSprint, true));
            sm.Transition(sprint, walk ?? idle, LocomotionBlend, false, StateMachineBuilder.Bool(pSprint, false));
        }

        var machine = graph.StateMachine("Weapon", sm, -100, 0);
        graph.Root(machine, 200, 0);

        summary = new GraphSummary(
            states,
            graph.Parameters.Keys.OrderBy(k => k, StringComparer.Ordinal).ToList(),
            new[] { WeaponParams.TagAttackDiscouraged, WeaponParams.TagHolsterFinished, WeaponParams.TagReloadIncrement }
                .Where(t => t switch
                {
                    WeaponParams.TagHolsterFinished => holsterFinished != 0,
                    WeaponParams.TagReloadIncrement => reloadIncrement != 0,
                    _ => true,
                }).ToList());

        return graph.Serialize(previewModel is null ? Array.Empty<string>() : new[] { previewModel }, cameraBone);
    }
}