void AddSpeedEffects()
        {
            // Adds speed modifier SpEffects (9000 range) to GPARAM and registers in dictionary for easy lookup here.
            int speedEffectCount = (int)(MaxSpeedMultiplier / QuantizedSpeed);

            for (int i = 1; i <= speedEffectCount; i++)
            {
                float    speed  = (float)Math.Round(i * QuantizedSpeed, 2);
                SpEffect effect = Mod.GPARAM.SpEffects.CopyRow(81001, 9000 + i);
                effect.Name                     = $"SpeedMultiplier ({speed:0.00}x)";
                effect.EffectDuration           = 0;
                effect.SpecialState             = 0;
                effect.SpecialEffectCategory    = 0;
                effect.AnimationSpeedMultiplier = speed;
                effect.CanAffectAll             = true;
                SpeedEffects[speed]             = Convert.ToInt32(effect);
            }
        }
        void AddAttackDamageEffects()
        {
            // Adds blanket attack damage modifier SpEffects (9100 range) to GPARAM and registers in dictionary for easy lookup here.
            int attackEffectCount = (int)(2.0f / 0.05f);

            for (int i = 1; i <= attackEffectCount; i++)
            {
                float    attackMultiplier = (float)Math.Round(i * 0.05, 2);
                SpEffect effect           = Mod.GPARAM.SpEffects.CopyRow(81001, 9100 + i);
                effect.Name           = $"AttackDamageMultiplier ({attackMultiplier:0.00}x)";
                effect.EffectDuration = 0;
                effect.OutgoingPhysicalDamageMultiplier  = attackMultiplier;
                effect.OutgoingMagicDamageMultiplier     = attackMultiplier;
                effect.OutgoingFireDamageMultiplier      = attackMultiplier;
                effect.OutgoingLightningDamageMultiplier = attackMultiplier;
                effect.OutgoingStaminaDamageMultiplier   = attackMultiplier;
                effect.CanAffectAll = true;
                AttackDamageEffects[attackMultiplier] = Convert.ToInt32(effect);
            }
        }
        void RandomizeBehaviorAnimationSpeed(TAE.Animation behaviorAnim, NPCAnimationInfo info, bool isPlayer = false)
        {
            /* Apply random speed modifiers throughout given attack animation (includes behaviors that trigger Bullets and SpEffects).
             *  - The earliest possible time for the hitbox behavior to start is Min(oldStartTime, Max(0.5, 0.5 * oldStartTime, hitboxRadius)).
             *  - The latest possible time for the hitbox is 0.5 * (oldStart - minStart), clamped between [minMax] and [maxMax].
             *  - RoryAlgorithm is more likely to add speed to valid frames that already have more speed, which leads to less noisy functions.
             *  - Hitbox radius of bullets is estimated, but does not take projectile speed into account. Fast, small bullets may see large speed boosts.
             *  - Non-Bullet attack damage is scaled by [minAttackScale] (if earliest possible time) to [maxAttackScale] (if latest possible time), rounded to the nearest 0.1.
             *  - Bullets themselves are unchanged here and randomized separately.
             */
            float hitboxRadius      = 1.0f; // default
            float behaviorStartTime = -1.0f;
            float behaviorEndTime   = -1.0f;

            if (info.InvokeAttackEvent != null)
            {
                Behavior attackBehavior = info.GetAttackBehavior();
                if (attackBehavior == null)
                {
                    return;                          // Missing Behavior param, which means it is most likely unused. No randomization.
                }
                Attack attack = info.GetAttack();
                if (attack == null)
                {
                    return;                  // Missing Attack param, which means it is most likely unused. No randomization.
                }
                hitboxRadius      = attack.Hitbox0Radius;
                behaviorStartTime = info.InvokeAttackEvent.StartTime;
                behaviorEndTime   = info.InvokeAttackEvent.EndTime;
            }
            else if (info.InvokeBulletEvent != null)
            {
                Behavior bulletBehavior = info.GetBulletBehavior();
                if (bulletBehavior == null)
                {
                    return;                          // Missing Behavior param, which means it is most likely unused. No randomization.
                }
                if (bulletBehavior.ReferenceType == 1)
                {
                    Bullet bullet = info.GetBullet();
                    if (bullet == null)
                    {
                        return;                  // Missing Bullet param.
                    }
                    hitboxRadius = Tools.GuessBulletRadius(bullet, Mod);
                    // Console.WriteLine($"    Final bullet ({bullet.ID}) radius of animation {behaviorAnim.ID}: {hitboxRadius:0.00}");
                    if (hitboxRadius == -1.0f)
                    {
                        hitboxRadius = 1.0f;                         // default for bullets with no final radius
                    }
                }
                else if (bulletBehavior.ReferenceType == 2)
                {
                    SpEffect spEffect = info.GetSpEffect();
                    if (spEffect == null)
                    {
                        return;           // Missing SpEffect param.
                    }
                    hitboxRadius = 1.0f;  // leave as default for SpEffect
                }
                behaviorStartTime = info.InvokeBulletEvent.StartTime;
                behaviorEndTime   = info.InvokeBulletEvent.EndTime;
            }
            if (behaviorStartTime == -1.0f || behaviorEndTime == -1.0f)
            {
                throw new ArgumentException($"Behavior start/end times were not set.");
            }

            float      minBehaviorStartTime   = Math.Min(behaviorStartTime, Math.Max(0.5f, Math.Max(0.5f * behaviorStartTime, MinAttackBehaviorHitboxScalar * hitboxRadius)));
            float      maxBehaviorStartTime   = behaviorStartTime + Math.Max(MinMaxAttackBehaviorDelay, Math.Min(0.5f * (behaviorStartTime - minBehaviorStartTime), MaxMaxAttackBehaviorDelay));
            float      newBehaviorStartTime   = minBehaviorStartTime + (float)Rand.NextDouble() * (maxBehaviorStartTime - minBehaviorStartTime);
            List <int> preAttackSpeedFunction = GetRandomSpeedFunction(0.0f, behaviorStartTime, newBehaviorStartTime);

            ApplySpeedFunction(behaviorAnim, preAttackSpeedFunction);
            if (DEBUG)
            {
                Console.WriteLine($"\nANIMATION {behaviorAnim.ID}");
                Console.WriteLine($"    Attack start time: {behaviorStartTime} => {newBehaviorStartTime}");
                Console.WriteLine($"    Random min/max: {minBehaviorStartTime}, {maxBehaviorStartTime}");
                Tools.DrawSpeedFunction(preAttackSpeedFunction, MaxSpeedMultiplier, QuantizedSpeed);
            }
            if (info.InvokeAttackEvent != null)
            {
                float attackPowerFactor      = (newBehaviorStartTime - minBehaviorStartTime) / (maxBehaviorStartTime - minBehaviorStartTime);
                float attackDamageMultiplier = MinAttackScale + attackPowerFactor * (MaxAttackScale - MinAttackScale);
                ApplySpeedAttackDamageMultiplier(behaviorAnim, attackDamageMultiplier, behaviorStartTime - FrameDuration, behaviorEndTime + FrameDuration);
            }
            // Mild random speed change during InvokeBehaviorEvent itself (multiplier of 1.0, 1.1, or 1.2).
            int   duringSpeedOptionCount = (int)(0.2f / QuantizedSpeed) + 1;
            float duringAttackSpeed      = 1.0f + (Rand.Next(duringSpeedOptionCount) * QuantizedSpeed);
            int   duringSpEffectID       = SpeedEffects[(float)Math.Round(duringAttackSpeed, 2)];

            behaviorAnim.ApplyEffect(duringSpEffectID, behaviorStartTime, behaviorEndTime);

            // Post-attack speed function (if cancel event is present to approximate end of animation).
            if (info.AnimationCancelEventEnd != -1.0f)
            {
                float animationEndTime    = info.AnimationCancelEventEnd;
                float minEndRealTime      = behaviorEndTime + (0.5f * (animationEndTime - behaviorEndTime));
                float maxEndRealTime      = behaviorEndTime + (1.3f * (animationEndTime - behaviorEndTime));
                float newAnimationEndTime = minEndRealTime + (float)Rand.NextDouble() * (maxEndRealTime - minEndRealTime);
                if (DEBUG)
                {
                    Console.WriteLine($"    Attack end time: {behaviorEndTime} (before pre-attack speed change)");
                    Console.WriteLine($"    Animation end time: {animationEndTime} => {newAnimationEndTime}");
                    Console.WriteLine($"    Random min/max: {minEndRealTime}, {maxEndRealTime}");
                }
                List <int> postAttackSpeedFunction = GetRandomSpeedFunction(behaviorEndTime, animationEndTime, newAnimationEndTime);
                ApplySpeedFunction(behaviorAnim, postAttackSpeedFunction);
            }
        }