XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

DeathMod

【Terraria】DeathMod (now aka Soul Harvest)

公开
关注 0 Fork 0 Star 0
UTF-8
using SoulHarvest.Common;
using SoulHarvest.Items;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;
using System.IO;
using Terraria;
using Terraria.Audio;
using Terraria.GameContent;
using Terraria.ID;
using Terraria.ModLoader;

namespace SoulHarvest.Projectiles;

public sealed class SickleSwingProjectile : ModProjectile
{
    private const int HistoryLength = 18;
    private const int AuthorityWaitTimeoutFrames = 180;
    private readonly Vector2[] tipHistory = new Vector2[HistoryLength];
    private readonly float[] angleHistory = new float[HistoryLength];
    private SickleCombatSnapshot snapshot;
    private int phase;
    private int timer;
    private int duration;
    private float aimRotation;
    private float weaponAngle;
    private Vector2 grip;
    private bool configured;
    private bool initialized;
    private bool released;
    private bool residualRiftSpawned;
    private bool deathDomainRiftSpawned;
    private bool infernalTrailSpawned;
    private bool movementTriggered;
    private bool receivedAuthoritativeState;
    private int authorityWaitFrames;
    private bool canDamage;
    private bool serverValidated;
    private int authoritativeDamage;
    private Vector2 authoritativeVelocity;
    private int authoritativeSwingDirection;
    private int validHistorySamples;
    private long deathCrescentVisualInstanceId;
    private float animationProgress;
    private float previousCollisionAngle;
    private bool collisionAngleInitialized;
    private float deathTempo = 1f;
    private bool deathAssemblyRequired;
    private bool deathAssemblyCancelling;
    private int deathAssemblyCancelStartTimer;
    private int deathAssemblyCancelTimer;
    private Vector2 infernalMoveStart;
    private Vector2 infernalMoveEnd;
    private bool infernalMoveReady;
    private int infernalMoveFramesRemaining;
    private Vector2 infernalMoveVelocity;

    internal bool ServerValidated => serverValidated;
    private int DeathAssemblyDuration => snapshot.Form == ReaperFormId.Death
        && deathAssemblyRequired
        ? snapshot.AssemblyFrames : 0;
    private bool IsDeathAssemblyActive => snapshot.Form == ReaperFormId.Death
        && deathAssemblyRequired && !deathAssemblyCancelling
        && timer <= DeathAssemblyDuration;
    internal bool BlocksNewPrimarySwing => !configured || !initialized
        || deathAssemblyCancelling
        || timer < duration + DeathAssemblyDuration;
    internal bool DeathAssemblyComplete => snapshot.Form != ReaperFormId.Death
        || !deathAssemblyRequired || timer >= DeathAssemblyDuration;
    internal bool DeathAssemblyRequired => deathAssemblyRequired;
    internal bool DeathAssemblyCancelling => deathAssemblyCancelling;
    internal float DeathTempo => deathTempo;
    internal bool DeathSpaceBreak => snapshot.Form == ReaperFormId.Death
        && snapshot.DeathSpaceBreakUnlocked
        && deathTempo >= snapshot.DeathPrimaryMaximumTempo - 0.01f;
    private int ItemType => (int)Projectile.ai[2];
    private int SwingDirection => Math.Sign(Projectile.ai[0]) == 0 ? 1 : Math.Sign(Projectile.ai[0]);
    private int VisualStage => snapshot.Form == ReaperFormId.Death ? 3 : snapshot.StageNumber;
    // Death primary attacks are one oversized Death-scythe sequence.  Do not
    // remap its six combo beats to branch forms: doing so reintroduced branch
    // scythes, mirrors and execution silhouettes even after their projectiles
    // had been removed.
    private ReaperFormId VisualForm => snapshot.Form;
    private bool IsComboFinisher => phase >= ReaperCombatRegistry.GetPrimaryPhaseCount(snapshot.Form, snapshot.Stage) - 1;
    public override string Texture => "Terraria/Images/Projectile_0";

    internal static bool HasBlockingPrimarySwing(Player player)
    {
        int swingType = ModContent.ProjectileType<SickleSwingProjectile>();
        foreach (Projectile projectile in Main.ActiveProjectiles)
        {
            if (projectile.owner == player.whoAmI
                && projectile.type == swingType
                && projectile.ModProjectile is SickleSwingProjectile swing
                && swing.BlocksNewPrimarySwing)
            {
                return true;
            }
        }
        return false;
    }

    internal void Configure(in SickleCombatSnapshot value, int attackPhase,
        float primaryDeathTempo = 1f, bool authorizeServer = false,
        bool requireDeathAssembly = true)
    {
        snapshot = value;
        phase = Math.Max(0, attackPhase);
        deathAssemblyRequired = snapshot.Form == ReaperFormId.Death
            && requireDeathAssembly;
        deathAssemblyCancelling = false;
        deathAssemblyCancelStartTimer = 0;
        deathAssemblyCancelTimer = 0;
        deathTempo = snapshot.Form == ReaperFormId.Death
            ? MathHelper.Clamp(primaryDeathTempo, 1f,
                snapshot.DeathPrimaryMaximumTempo)
            : 1f;
        deathCrescentVisualInstanceId = 0;
        configured = true;
        Projectile.damage = Math.Max(1, (int)Math.Round(snapshot.Damage * ReaperCombatRegistry.GetPrimaryDamageMultiplier(snapshot, phase)));
        Projectile.originalDamage = snapshot.Damage;
        Projectile.CritChance = snapshot.CritChance;
        float attackAngle = Projectile.velocity.ToRotation();
        Projectile.GetGlobalProjectile<MyGlobalProjectile>().ConfigureReaperProjectile(
            Projectile, snapshot, ReaperHitKind.Primary, phase, attackAngle);
        if (Main.netMode == NetmodeID.Server && authorizeServer)
        {
            serverValidated = true;
            authoritativeDamage = Projectile.damage;
            authoritativeVelocity = Projectile.velocity.SafeNormalize(Vector2.UnitX * Main.player[Projectile.owner].direction);
            authoritativeSwingDirection = SwingDirection;
        }
        Projectile.netUpdate = true;
    }

    public override void SetDefaults()
    {
        Projectile.width = 24;
        Projectile.height = 24;
        Projectile.friendly = true;
        Projectile.tileCollide = false;
        Projectile.ignoreWater = true;
        Projectile.penetrate = -1;
        // Reaper blades intentionally cut through terrain.  Their authoritative
        // swept geometry is sufficient; Terraria's owner line-of-sight gate
        // would otherwise reject a valid hit whenever a tile is between them.
        Projectile.ownerHitCheck = false;
        Projectile.hide = true;
        Projectile.timeLeft = 120;
        Projectile.DamageType = DamageClass.Melee;
        Projectile.usesLocalNPCImmunity = true;
        Projectile.localNPCHitCooldown = NormalSickle.BaseHitCooldownFrames;
    }

    public override bool ShouldUpdatePosition() => false;
    public override bool? CanDamage() => configured && canDamage ? null : false;

    internal static void ApplyHarvestUppercut(NPC target, int direction, float knockback)
    {
        if (target.boss)
            return;
        float resistance = MathHelper.Clamp(target.knockBackResist, 0.15f, 1f);
        target.velocity.Y = Math.Min(target.velocity.Y, -(6.5f + knockback * 0.35f) * resistance);
        target.velocity.X += direction * (1.8f + knockback * 0.12f) * resistance;
        target.netUpdate = true;
    }

    public override void AI()
    {
        if (!TryGetOwner(out Player player))
        {
            Projectile.Kill();
            return;
        }
        if (Main.netMode == NetmodeID.Server && !serverValidated)
        {
            if (!float.IsFinite(Projectile.velocity.X) || !float.IsFinite(Projectile.velocity.Y))
            {
                Projectile.Kill();
                return;
            }
            int serverDamage = Math.Max(1, player.GetWeaponDamage(player.HeldItem));
            float authoritativeKnockback = player.GetWeaponKnockback(player.HeldItem, player.HeldItem.knockBack);
            SickleCombatSnapshot authoritative = SickleCombatSnapshot.Capture(player, serverDamage, authoritativeKnockback);
            if (!ReaperCombatService.TryAuthorizePrimarySwing(player, Projectile, authoritative,
                    out int authoritativePhase, out float authoritativeDeathTempo,
                    out bool authoritativeAssemblyRequired))
            {
                Projectile.Kill();
                return;
            }
            Projectile.velocity = Projectile.velocity.SafeNormalize(Vector2.UnitX * player.direction);
            Projectile.ai[0] = player.GetModPlayer<MyPlayer>().TakeNextSwingDirection(player.direction);
            Projectile.ai[2] = player.HeldItem.type;
            Configure(authoritative, authoritativePhase, authoritativeDeathTempo,
                authorizeServer: true,
                requireDeathAssembly: authoritativeAssemblyRequired);
        }
        else if (!configured)
        {
            int damage = Math.Max(1, Projectile.originalDamage > 0 ? Projectile.originalDamage : Projectile.damage);
            Configure(SickleCombatSnapshot.Capture(player, damage, Projectile.knockBack), 0);
        }
        if (Main.netMode == NetmodeID.Server)
        {
            Projectile.damage = authoritativeDamage;
            Projectile.velocity = authoritativeVelocity;
            Projectile.ai[0] = authoritativeSwingDirection;
        }
        if (!initialized)
            Initialize(player);

        bool debugSimulatedPrimaryHeld = false;
#if DEBUG
        debugSimulatedPrimaryHeld = ReaperMultiplayerClientSelfTest
            .SimulatedPrimaryHeld;
#endif
        if (!deathAssemblyCancelling && IsDeathAssemblyActive
            && (Main.netMode == NetmodeID.SinglePlayer
                || Main.netMode == NetmodeID.MultiplayerClient
                    && Projectile.owner == Main.myPlayer)
            && !player.controlUseItem
            && !debugSimulatedPrimaryHeld)
        {
            RequestDeathAssemblyCancellation();
        }
        if (deathAssemblyCancelling)
        {
            UpdateDeathAssemblyCancellation(player);
            return;
        }

        timer++;
        Projectile.timeLeft = 2;
        Projectile.Center = player.MountedCenter;
        grip = player.MountedCenter;
        if (snapshot.Form == ReaperFormId.Death
            && timer <= DeathAssemblyDuration)
        {
            player.heldProj = Projectile.whoAmI;
            player.itemTime = 2;
            player.itemAnimation = 2;
            player.ChangeDir(Projectile.velocity.X >= 0f ? 1 : -1);
            animationProgress = 0f;
            UpdatePose(0f);
            previousCollisionAngle = weaponAngle;
            collisionAngleInitialized = true;
            canDamage = false;
            player.SetCompositeArmFront(true,
                Player.CompositeArmStretchAmount.Full,
                weaponAngle - MathHelper.PiOver2);
            player.itemRotation = MathHelper.WrapAngle(weaponAngle);
            if (timer == DeathAssemblyDuration)
                ReaperCombatService.CompleteDeathPrimaryAssembly(player);
            return;
        }

        int attackTimer = timer - DeathAssemblyDuration;
        float progress = MathHelper.Clamp(attackTimer / (float)duration, 0f, 1f);
        if (progress < 1f)
        {
            player.heldProj = Projectile.whoAmI;
            player.itemTime = 2;
            player.itemAnimation = 2;
            player.ChangeDir(Projectile.velocity.X >= 0f ? 1 : -1);
        }
        else if (player.heldProj == Projectile.whoAmI)
        {
            // A predicted multiplayer swing may remain alive briefly while it
            // waits for its server authorization snapshot. That hidden,
            // non-damaging wait must never keep the player's item locked or make
            // the next click stall for the full authority timeout.
            player.heldProj = -1;
        }
        animationProgress = progress;
        float previousPoseAngle = weaponAngle;
        UpdatePose(progress);
        if (!collisionAngleInitialized)
        {
            // Do not join the cursor-facing initialization pose to the first real
            // wind-up pose; that chord was never crossed by the visible weapon.
            previousCollisionAngle = weaponAngle;
            collisionAngleInitialized = true;
        }
        else
        {
            previousCollisionAngle = previousPoseAngle;
        }
        if (Main.netMode != NetmodeID.Server
            && snapshot.Form == ReaperFormId.Death)
        {
            RecordDeathPrimaryCrescent();
        }
        if (snapshot.Form == ReaperFormId.Void || snapshot.Form == ReaperFormId.Death && phase == 5)
        {
            Projectile.GetGlobalProjectile<MyGlobalProjectile>()
                .UpdateReaperAttackAngle(weaponAngle);
        }
        canDamage = progress is > 0.02f and < 0.98f;
        ConfirmServerTrainingDummyContact();
        if (!released && progress >= 0.4f)
        {
            released = true;
            ReaperCombatService.OnPrimaryReleased(Projectile, snapshot, phase, player.MountedCenter, aimRotation.ToRotationVector2());
            if (Main.netMode != NetmodeID.Server && VisualStage >= 3 && IsComboFinisher)
                TriggerPrimaryFinisherImpact();
        }
        UpdateMovement(player);
        UpdateHistory(progress);
        if (!residualRiftSpawned && snapshot.Form == ReaperFormId.Void
            && progress >= 0.88f && validHistorySamples >= 2
            && Main.netMode != NetmodeID.MultiplayerClient)
        {
            residualRiftSpawned = true;
            ReaperVoidArcRiftProjectile.Spawn(Projectile.GetSource_FromThis(),
                Projectile.owner, snapshot, tipHistory, validHistorySamples,
                phase, Projectile.identity);
        }
        if (!deathDomainRiftSpawned && DeathSpaceBreak && progress >= 0.98f
            && Main.netMode != NetmodeID.MultiplayerClient)
        {
            deathDomainRiftSpawned = true;
            SpawnDeathDomainRift();
        }
        if (!infernalTrailSpawned && snapshot.Form == ReaperFormId.Infernal
            && progress >= 0.88f && infernalMoveReady
            && Main.netMode != NetmodeID.MultiplayerClient)
        {
            infernalTrailSpawned = true;
            ReaperInfernalTrailProjectile.Spawn(Projectile.GetSource_FromThis(),
                Projectile.owner, snapshot, infernalMoveStart, infernalMoveEnd,
                10f + snapshot.StageNumber * 2.5f, phase, Projectile.identity);
        }
        if (Main.netMode != NetmodeID.Server && VisualForm == ReaperFormId.Void
            && validHistorySamples > 0)
        {
            ReaperVoidTrailVisualSystem.RecordTip(Projectile.owner, Projectile.identity,
                (ReaperStage)Math.Clamp(VisualStage, 1, 3), tipHistory[0]);
        }
        player.SetCompositeArmFront(true, Player.CompositeArmStretchAmount.Full, weaponAngle - MathHelper.PiOver2);
        player.itemRotation = MathHelper.WrapAngle(weaponAngle);
        SpawnMotionDust();
        if (progress >= 1f)
        {
            // Never let the held-projectile cleanup frame join its final pose to
            // stale history. That single terminal chord was the long "laser" seen
            // after an otherwise continuous swing.
            InvalidateHistory();

            // The owning multiplayer client must not tell the server to kill a
            // predicted swing before its authoritative snapshot returns. With a
            // long RTT the shortest phases can otherwise end locally first and
            // cancel their still-valid server damage. Hold a harmless terminal
            // pose until validation (or a bounded disconnect-safe timeout).
            bool awaitingAuthority = Main.netMode == NetmodeID.MultiplayerClient
                && Projectile.owner == Main.myPlayer
                && !receivedAuthoritativeState
                && authorityWaitFrames++ < AuthorityWaitTimeoutFrames;
            if (awaitingAuthority)
            {
                canDamage = false;
                return;
            }
            Projectile.Kill();
        }
    }

    internal bool RequestDeathAssemblyCancellation()
    {
        if (!configured || snapshot.Form != ReaperFormId.Death
            || !deathAssemblyRequired || deathAssemblyCancelling
            || timer >= DeathAssemblyDuration)
        {
            return false;
        }

        deathAssemblyCancelling = true;
        deathAssemblyCancelStartTimer = Math.Clamp(timer, 0,
            DeathAssemblyDuration);
        deathAssemblyCancelTimer = 0;
        canDamage = false;
        released = false;
        Projectile.netUpdate = true;
        if (Main.netMode == NetmodeID.Server)
        {
            NetMessage.SendData(MessageID.SyncProjectile, -1, -1, null,
                Projectile.whoAmI);
        }
        return true;
    }

    private void UpdateDeathAssemblyCancellation(Player player)
    {
        deathAssemblyCancelTimer++;
        Projectile.timeLeft = 2;
        Projectile.Center = player.MountedCenter;
        grip = player.MountedCenter;
        player.heldProj = Projectile.whoAmI;
        player.itemTime = 2;
        player.itemAnimation = 2;
        player.ChangeDir(Projectile.velocity.X >= 0f ? 1 : -1);
        animationProgress = 0f;
        UpdatePose(0f);
        previousCollisionAngle = weaponAngle;
        collisionAngleInitialized = true;
        canDamage = false;
        player.SetCompositeArmFront(true,
            Player.CompositeArmStretchAmount.Full,
            weaponAngle - MathHelper.PiOver2);
        player.itemRotation = MathHelper.WrapAngle(weaponAngle);
        if (deathAssemblyCancelTimer >= ReaperDefinitions.AssemblyCancelFrames)
            Projectile.Kill();
    }

    public override bool? Colliding(Rectangle projHitbox, Rectangle targetHitbox)
    {
        if (!configured || !canDamage)
            return false;
        float width = ReaperCombatRegistry.GetCollisionWidth(snapshot);
        float reach;
        if (snapshot.Form == ReaperFormId.Death)
        {
            reach = ReaperCombatRegistry.GetDeathPrimaryCollisionReach(snapshot);
            width = 74f;
        }
        else if (snapshot.Form == ReaperFormId.Void)
        {
            // The Void primary is a sweeping crescent, not a stationary spatial
            // laser. Its authoritative line follows the live weapon angle so the
            // damage edge travels through the same arc shown to the player.
            reach = GetVoidVisibleReach();
            width = Math.Max(width, GetVoidPrimaryWidth());
        }
        else if (snapshot.Form == ReaperFormId.Blood)
        {
            float reachScale = snapshot.Stage switch
            {
                ReaperStage.StageI => 0.98f,
                ReaperStage.StageII => IsComboFinisher ? 1.08f : 1.03f,
                _ => IsComboFinisher ? 1.14f : 1.08f
            };
            reach = ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage) * reachScale;
            width = snapshot.Stage switch
            {
                ReaperStage.StageI => 20f,
                ReaperStage.StageII => IsComboFinisher ? 28f : 24f,
                _ => IsComboFinisher ? 34f : 28f
            };
        }
        else
        {
            reach = Vector2.Distance(grip, GetBladeTip(grip, weaponAngle));
        }
        return CheckSweptBladeCollision(targetHitbox, reach, width);
    }

    private bool CheckSweptBladeCollision(Rectangle targetHitbox, float reach,
        float width)
    {
        // The old inner-radius exclusion left a very large blind spot (over 200
        // pixels for Death).  Cover the handle area explicitly, then sweep every
        // blade sample all the way from the grip to the visible cutting edge.
        float closeRadius = Math.Clamp(width * 0.65f, 32f, 64f);
        Vector2 closest = new(
            MathHelper.Clamp(grip.X, targetHitbox.Left, targetHitbox.Right),
            MathHelper.Clamp(grip.Y, targetHitbox.Top, targetHitbox.Bottom));
        if (Vector2.DistanceSquared(grip, closest) <= closeRadius * closeRadius)
            return true;

        float angularDelta = MathHelper.WrapAngle(weaponAngle
            - previousCollisionAngle);
        float sweptDistance = Math.Abs(angularDelta) * reach;
        // No point on the hot edge may travel farther than roughly half the
        // collision thickness between samples. This closes the high-speed holes
        // without changing the total damage or adding extra attack events.
        int samples = Math.Clamp((int)Math.Ceiling(sweptDistance
            / Math.Max(5f, width * 0.48f)), 1, 72);
        float collisionPoint = 0f;
        for (int sample = 0; sample <= samples; sample++)
        {
            float angle = previousCollisionAngle
                + angularDelta * (sample / (float)samples);
            Vector2 direction = angle.ToRotationVector2();
            Vector2 start = grip;
            Vector2 end = grip + direction * reach;
            if (Collision.CheckAABBvLineCollision(targetHitbox.TopLeft(),
                targetHitbox.Size(), start, end, width, ref collisionPoint))
            {
                return true;
            }
        }
        return false;
    }

    private void ConfirmServerTrainingDummyContact()
    {
        if (Main.netMode != NetmodeID.Server || !serverValidated || !canDamage)
            return;

        MyGlobalProjectile hitData = Projectile.GetGlobalProjectile<
            MyGlobalProjectile>();
        foreach (NPC npc in Main.ActiveNPCs)
        {
            // Vanilla's immortal target dummy can display the owning client's
            // damage text without invoking the dedicated server's projectile-hit
            // callback. Confirm only this exceptional target from the same
            // authoritative swept-blade geometry, then run the normal primary
            // hit side effects exactly once. No damage or hitbox is changed.
            if (!ReaperTargeting.IsTrainingDummy(npc)
                || Colliding(Projectile.Hitbox, npc.Hitbox) != true)
                continue;
            hitData.TryApplyPrimaryHitEffects(Projectile, npc, 0);
        }
    }

    public override bool PreDraw(ref Color lightColor)
    {
        if (Main.dedServ || !configured)
            return false;
        if (deathAssemblyCancelling || IsDeathAssemblyActive)
        {
            Texture2D assemblyTexture = ModContent.Request<Texture2D>(
                ReaperCombatRegistry.GetTexturePath(snapshot.Form,
                    snapshot.Stage)).Value;
            SpriteEffects assemblyEffects = GetWeaponEffects(0);
            Vector2 normalizedAnchor = ReaperCombatRegistry.GetHandleAnchor(
                snapshot.Form, snapshot.Stage);
            Vector2 assemblyAnchor = new(
                assemblyTexture.Width * normalizedAnchor.X,
                assemblyTexture.Height * normalizedAnchor.Y);
            if ((assemblyEffects & SpriteEffects.FlipVertically) != 0)
                assemblyAnchor.Y = assemblyTexture.Height - assemblyAnchor.Y;
            float assemblyScale = ReaperCombatRegistry
                .GetDeathWeaponDrawScale(snapshot);
            float assemblyRotation = weaponAngle
                + ReaperCombatRegistry.GetTextureRotationCorrection(
                    snapshot.Form, snapshot.Stage);
            int actionSeed = Projectile.identity * 31 + phase * 997;
            if (deathAssemblyCancelling)
            {
                DeathSickleAssemblyTextureSystem.DrawCancellation(
                    assemblyTexture, grip, assemblyRotation, assemblyAnchor,
                    assemblyScale, assemblyEffects, lightColor,
                    deathAssemblyCancelStartTimer,
                    deathAssemblyCancelTimer, DeathAssemblyDuration,
                    actionSeed, largeDeathLayout: true);
            }
            else
            {
                DeathSickleAssemblyTextureSystem.Draw(assemblyTexture, grip,
                    assemblyRotation, assemblyAnchor, assemblyScale,
                    assemblyEffects, lightColor, timer,
                    DeathAssemblyDuration, actionSeed,
                    largeDeathLayout: true);
            }
            return false;
        }
        DrawFormBackdrop();
        DrawTrail();
        // Blood's chevron rune read as an aiming arrow, while Void's parallel
        // rune bars visibly crossed the torn background. Their trail surfaces are
        // already the form language, so do not stamp unrelated glyphs over them.
        if (HasStageVisual(ReaperStage.StageII)
            && VisualForm is not ReaperFormId.Blood and not ReaperFormId.Void)
            DrawStageRunes();
        Texture2D texture = ModContent.Request<Texture2D>(
            ReaperCombatRegistry.GetTexturePath(snapshot.Form, snapshot.Stage)).Value;
        float scale = snapshot.Form == ReaperFormId.Death
            ? ReaperCombatRegistry.GetDeathWeaponDrawScale(snapshot)
            : 0.86f + snapshot.StageNumber * 0.045f;
        SpriteEffects weaponEffects = GetWeaponEffects(0);
        DrawWeaponAfterimages(texture, scale);
        DrawWeapon(texture, grip, weaponAngle, lightColor, scale, weaponEffects);
        return false;
    }

    public override void OnHitNPC(NPC target, NPC.HitInfo hit, int damageDone)
    {
        if (Main.netMode == NetmodeID.Server)
            return;

        if (snapshot.Form == ReaperFormId.Base)
        {
            SpawnBasePrimaryImpact(target.Center);
            return;
        }

        // Void owns a synchronized, persistent impact rift.  Keeping the old
        // shadowflame burst as well made the contact read like an ordinary purple
        // magic hit and doubled the owner's feedback in multiplayer.
        if (snapshot.Form != ReaperFormId.Void)
            SpawnFormImpact(target.Center, VisualForm);
    }

    public override void SendExtraAI(BinaryWriter writer)
    {
        writer.Write(configured);
        if (!configured)
            return;
        snapshot.Write(writer);
        writer.Write((byte)Math.Clamp(phase, 0, byte.MaxValue));
        writer.Write((short)Math.Clamp(timer, 0, short.MaxValue));
        writer.Write(aimRotation);
        writer.Write(deathTempo);
        writer.Write(deathAssemblyRequired);
        writer.Write(deathAssemblyCancelling);
        writer.Write((short)Math.Clamp(deathAssemblyCancelStartTimer, 0,
            short.MaxValue));
        writer.Write((short)Math.Clamp(deathAssemblyCancelTimer, 0,
            short.MaxValue));
        writer.Write(infernalMoveReady);
        if (infernalMoveReady)
        {
            writer.WriteVector2(infernalMoveStart);
            writer.WriteVector2(infernalMoveEnd);
        }
    }

    public override void ReceiveExtraAI(BinaryReader reader)
    {
        bool incomingConfigured = reader.ReadBoolean();
        if (!incomingConfigured)
        {
            if (Main.netMode != NetmodeID.Server)
                configured = false;
            return;
        }
        SickleCombatSnapshot incomingSnapshot = SickleCombatSnapshot.Read(reader);
        int incomingPhase = reader.ReadByte();
        int incomingTimer = reader.ReadInt16();
        float incomingAimRotation = reader.ReadSingle();
        float incomingDeathTempo = MathHelper.Clamp(reader.ReadSingle(), 1f,
            incomingSnapshot.DeathPrimaryMaximumTempo);
        bool incomingDeathAssemblyRequired = reader.ReadBoolean();
        bool incomingDeathAssemblyCancelling = reader.ReadBoolean();
        int incomingDeathAssemblyCancelStartTimer = Math.Max(0,
            (int)reader.ReadInt16());
        int incomingDeathAssemblyCancelTimer = Math.Max(0,
            (int)reader.ReadInt16());
        bool incomingInfernalMoveReady = reader.ReadBoolean();
        Vector2 incomingInfernalMoveStart = incomingInfernalMoveReady
            ? reader.ReadVector2()
            : Vector2.Zero;
        Vector2 incomingInfernalMoveEnd = incomingInfernalMoveReady
            ? reader.ReadVector2()
            : Vector2.Zero;
        if (Main.netMode == NetmodeID.Server)
            return;

        // A late authoritative packet can correct the locally predicted form,
        // phase, aim, or animation time. Never join samples from the two poses:
        // doing so draws one bright chord across the otherwise continuous arc.
        bool poseWasCorrected = initialized
            && (snapshot.Form != incomingSnapshot.Form
                || snapshot.Stage != incomingSnapshot.Stage
                || phase != incomingPhase
                || Math.Abs(MathHelper.WrapAngle(aimRotation - incomingAimRotation)) > 0.08f
                || Math.Abs(timer - incomingTimer) > 2
                || deathAssemblyRequired != incomingDeathAssemblyRequired
                || deathAssemblyCancelling
                    != incomingDeathAssemblyCancelling);
        configured = true;
        snapshot = incomingSnapshot;
        phase = incomingPhase;
        timer = incomingTimer;
        aimRotation = incomingAimRotation;
        deathTempo = incomingSnapshot.Form == ReaperFormId.Death
            ? incomingDeathTempo : 1f;
        deathAssemblyRequired = incomingDeathAssemblyRequired;
        deathAssemblyCancelling = incomingDeathAssemblyCancelling;
        deathAssemblyCancelStartTimer = incomingDeathAssemblyCancelStartTimer;
        deathAssemblyCancelTimer = incomingDeathAssemblyCancelTimer;
        infernalMoveReady = incomingInfernalMoveReady;
        infernalMoveStart = incomingInfernalMoveStart;
        infernalMoveEnd = incomingInfernalMoveEnd;
        receivedAuthoritativeState = true;
        authorityWaitFrames = 0;
        int correctedDuration = CalculateDuration(snapshot, phase, deathTempo);
        if (initialized)
            duration = correctedDuration;

        // One-shot release and movement beats must never replay when prediction
        // is rewound by an authoritative packet. A client which first sees an
        // already-past release skips that old beat instead of firing it late.
        int releaseTick = Math.Max(1, (int)Math.Ceiling(correctedDuration * 0.4f));
        released |= incomingTimer >= releaseTick;
        if (incomingTimer > 4)
            movementTriggered = true;
        if (poseWasCorrected)
            InvalidateHistory();
    }

    private void Initialize(Player player)
    {
        initialized = true;
        if (!receivedAuthoritativeState)
            aimRotation = Projectile.velocity.SafeNormalize(Vector2.UnitX * player.direction).ToRotation();
        duration = CalculateDuration(snapshot, phase, deathTempo);
        grip = player.MountedCenter;
        weaponAngle = aimRotation;

        // ReceiveExtraAI can run before the first local AI tick. Preserve an
        // already synchronized infernal route instead of erasing it here.
        InvalidateHistory();
        if (Main.netMode != NetmodeID.Server)
        {
            SoundEngine.PlaySound(snapshot.Form switch
            {
                ReaperFormId.Bone => SoundID.Item71,
                ReaperFormId.Infernal => SoundID.Item74,
                ReaperFormId.Frost => SoundID.Item28,
                ReaperFormId.Void => SoundID.Item8,
                ReaperFormId.Death => SoundID.Item122,
                _ => SoundID.Item1
            }, player.Center);
        }
    }

    private void UpdatePose(float progress)
    {
        float eased = SmoothStep(progress);
        int direction = SwingDirection;
        switch (snapshot.Form)
        {
            case ReaperFormId.Base when phase == 1:
                SetArc(2.2f, -1.25f, eased, direction);
                break;
            case ReaperFormId.Bone:
                SetArc(-1.85f + phase * 0.22f, 1.12f + phase * 0.18f, eased, direction);
                grip.Y -= 3f;
                break;
            case ReaperFormId.Blood:
                SetArc(-2.85f, 2.42f, eased, direction);
                break;
            case ReaperFormId.Infernal:
                SetArc(2.15f - phase * 0.12f, -1.4f + phase * 0.15f, EaseOutSine(progress), direction);
                break;
            case ReaperFormId.Frost:
                SetArc(phase == 1 ? 2.45f : -2.45f, phase == 1 ? -1.2f : 1.2f, eased, direction);
                break;
            case ReaperFormId.Soul:
                SetArc(-3.05f, 2.75f, eased, direction);
                break;
            case ReaperFormId.Void:
                float voidSweep = VisualStage switch { 1 => 2.15f, 2 => 2.62f, _ => 3.08f };
                // Void attacks must read as an alternating physical swing rather
                // than a repeated cursor-relative flick. The first cut descends
                // from above the shoulder; the second retraces from below. Facing
                // mirrors the pose horizontally without changing that ordering.
                int voidFacing = Math.Cos(aimRotation) >= 0f ? 1 : -1;
                if ((phase & 1) == 0)
                    SetArc(-voidSweep * 0.52f, voidSweep * 0.52f, eased, voidFacing);
                else
                    SetArc(voidSweep * 0.52f, -voidSweep * 0.52f, eased, voidFacing);
                break;
            case ReaperFormId.Death:
                UpdateDeathPose(progress, eased, direction);
                break;
            default:
                SetArc(-2.08f, 1.2f, EaseOutCubic(progress), direction);
                break;
        }
    }

    private void UpdateDeathPose(float progress, float eased, int direction)
    {
        _ = progress;
        _ = phase;
        // A fixed sweep is what lets the weapon, revealed crescent, swept damage
        // sector and max-tempo residual all describe the exact same blade-tip arc.
        SetArc(-ReaperCombatRegistry.DeathPrimaryHalfSweep,
            ReaperCombatRegistry.DeathPrimaryHalfSweep, eased, direction);
    }

    private void SetArc(float startOffset, float endOffset, float progress, int direction)
    {
        weaponAngle = MathHelper.Lerp(aimRotation + startOffset * direction, aimRotation + endOffset * direction, progress);
    }

    private void UpdateMovement(Player player)
    {
        if (snapshot.Form == ReaperFormId.Infernal && infernalMoveFramesRemaining > 0
            && (Main.netMode != NetmodeID.MultiplayerClient || Projectile.owner == Main.myPlayer))
        {
            Vector2 candidate = player.position + infernalMoveVelocity;
            if (!Collision.SolidCollision(candidate, player.width, player.height))
                player.velocity = infernalMoveVelocity;
            else
                infernalMoveFramesRemaining = 0;
            if (infernalMoveFramesRemaining > 0)
                infernalMoveFramesRemaining--;
        }

        if (movementTriggered
            || timer < 2
            || Main.netMode == NetmodeID.MultiplayerClient && Projectile.owner != Main.myPlayer)
            return;

        movementTriggered = true;
        // A newly observed remote action may already be well past its movement
        // beat. Its authoritative player sync supplies the final position; do not
        // apply a stale local dash several frames late.
        if (timer > 4)
            return;
        if (snapshot.Form == ReaperFormId.Infernal)
        {
            float distance = snapshot.Stage switch
            {
                ReaperStage.StageI => 44f,
                ReaperStage.StageII => 62f,
                _ => 78f
            };
            Vector2 direction = aimRotation.ToRotationVector2();
            float accepted = ReaperMovementHelper.TraceAlong(player, direction, distance,
                out infernalMoveStart, out infernalMoveEnd);
            infernalMoveReady = accepted > 0f;
            infernalMoveFramesRemaining = snapshot.Stage switch
            {
                ReaperStage.StageI => 6,
                ReaperStage.StageII => 7,
                _ => 8
            };
            infernalMoveVelocity = infernalMoveFramesRemaining > 0
                ? (infernalMoveEnd - infernalMoveStart) / infernalMoveFramesRemaining
                : Vector2.Zero;
            Projectile.netUpdate = true;
        }
    }

    private static int CalculateDuration(in SickleCombatSnapshot value, int attackPhase,
        float primaryDeathTempo)
    {
        int baseDuration = ReaperCombatRegistry.GetUseTime(value.Form, value.Stage, attackPhase);
        int minimumDuration = value.Form == ReaperFormId.Death ? 3 : 12;
        float tempo = value.Form == ReaperFormId.Death ? primaryDeathTempo : 1f;
        float effectiveAttackSpeed = value.AttackSpeedMultiplier * tempo;
        if (value.Form == ReaperFormId.Death)
            effectiveAttackSpeed = Math.Min(effectiveAttackSpeed,
                value.DeathPrimaryMaximumTempo);
        return Math.Max(minimumDuration, (int)Math.Ceiling(baseDuration
            / Math.Max(0.1f, effectiveAttackSpeed)));
    }

    private void UpdateHistory(float progress)
    {
        // The final recovery frames do not add trail samples. Their pose can snap
        // to the held-item resting transform when Terraria disposes heldProj.
        if (progress >= 0.94f)
            return;

        // Sample the rendered weapon tip exactly. The former Void-only reach
        // estimate was longer than the texture and made the scene tear float in
        // front of the blade instead of being carved by its tip.
        Vector2 currentTip = snapshot.Form == ReaperFormId.Death
            ? grip + weaponAngle.ToRotationVector2()
                * ReaperCombatRegistry.GetDeathPrimaryBladeReach(snapshot)
            : GetBladeTip(grip, weaponAngle);

        // Initialize from the first evaluated animation pose, not aimRotation.
        // Most forms begin 1-3 radians away from the cursor; pre-filling history
        // at the cursor used to connect that point to the first swing pose and
        // produced the single-frame diameter/chord reported by players.
        if (validHistorySamples <= 0)
        {
            Array.Fill(tipHistory, currentTip);
            Array.Fill(angleHistory, weaponAngle);
            validHistorySamples = 1;
            return;
        }

        // Also sever history after a large network correction or teleport. Normal
        // per-tick angular motion (including Infernal movement) remains far below
        // this form-scaled limit, so legitimate arcs stay continuous.
        float discontinuityDistance = snapshot.Form == ReaperFormId.Death
            ? ReaperCombatRegistry.GetDeathPrimaryBladeReach(snapshot) * 0.96f
            : Math.Clamp(ReaperCombatRegistry.GetBladeTipLength(snapshot.Form,
                snapshot.Stage) * 0.62f, 62f, 118f);
        float angularJump = Math.Abs(MathHelper.WrapAngle(weaponAngle - angleHistory[0]));
        if (Vector2.DistanceSquared(currentTip, tipHistory[0])
                > discontinuityDistance * discontinuityDistance
            || angularJump > (snapshot.Form == ReaperFormId.Death ? 1.35f : 0.72f))
        {
            Array.Fill(tipHistory, currentTip);
            Array.Fill(angleHistory, weaponAngle);
            validHistorySamples = 1;
            return;
        }

        for (int index = HistoryLength - 1; index > 0; index--)
        {
            tipHistory[index] = tipHistory[index - 1];
            angleHistory[index] = angleHistory[index - 1];
        }
        tipHistory[0] = currentTip;
        angleHistory[0] = weaponAngle;
        validHistorySamples = Math.Min(HistoryLength, validHistorySamples + 1);
    }

    private void InvalidateHistory()
    {
        validHistorySamples = 0;
        Array.Clear(tipHistory, 0, tipHistory.Length);
        Array.Clear(angleHistory, 0, angleHistory.Length);
    }

    private void SpawnMotionDust()
    {
        if (Main.netMode == NetmodeID.Server || validHistorySamples <= 0
            || !Main.rand.NextBool(snapshot.Stage >= ReaperStage.StageIII ? 1 : snapshot.Stage >= ReaperStage.StageII ? 2 : 3))
            return;
        Color color = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form);
        Dust dust = Dust.NewDustPerfect(tipHistory[0], ReaperCombatRegistry.GetDust(snapshot.Form),
            Main.rand.NextVector2Circular(1.8f, 1.8f), 80, color, 0.8f + snapshot.StageNumber * 0.08f);
        dust.noGravity = true;
        Lighting.AddLight(tipHistory[0], color.ToVector3() * (0.3f + snapshot.StageNumber * 0.12f));
    }

    private void DrawFormBackdrop()
    {
        float fade = GetSwingVisibility();
        if (fade <= 0.01f)
            return;

        switch (VisualForm)
        {
            case ReaperFormId.Bone when VisualStage >= 2:
                DrawBoneRibBackdrop(fade);
                break;
            case ReaperFormId.Blood when VisualStage >= 2:
                DrawBloodMoonBackdrop(fade);
                break;
            case ReaperFormId.Infernal when VisualStage >= 2:
                DrawInfernalBackdrop(fade);
                break;
            case ReaperFormId.Frost when VisualStage >= 2:
                DrawFrostMirrorBackdrop(fade);
                break;
            case ReaperFormId.Soul:
                DrawSoulEchoBackdrop(fade);
                break;
        }

        if (snapshot.Form == ReaperFormId.Infernal)
            DrawInfernalMovementTrack(fade);

    }

    private void DrawBoneRibBackdrop(float fade)
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Vector2 direction = weaponAngle.ToRotationVector2();
        Vector2 normal = direction.RotatedBy(MathHelper.PiOver2);
        float reach = Vector2.Distance(grip, GetBladeTip(grip, weaponAngle))
            * (VisualStage >= 3 ? 1.08f : 0.96f);
        Color fire = new Color(30, 220, 255, 0);
        Color bone = new Color(242, 241, 211, 0);
        float opacity = fade * (IsComboFinisher || snapshot.Form == ReaperFormId.Death ? 0.72f : 0.42f);

        Vector2 shoulder = grip - direction * 13f;
        Vector2 elbow = grip + direction * reach * 0.43f + normal * SwingDirection * 20f;
        Vector2 wrist = grip + direction * reach * 0.91f;
        DrawVisualSegment(pixel, shoulder, elbow, fire * (opacity * 0.44f), VisualStage >= 3 ? 17f : 11f);
        DrawVisualSegment(pixel, shoulder, elbow, bone * opacity, VisualStage >= 3 ? 6.5f : 4.2f);
        DrawVisualSegment(pixel, elbow, wrist + normal * 6f, fire * (opacity * 0.42f), VisualStage >= 3 ? 13f : 8f);
        DrawVisualSegment(pixel, elbow, wrist + normal * 6f, bone * opacity, VisualStage >= 3 ? 4.5f : 3f);
        DrawVisualSegment(pixel, elbow, wrist - normal * 6f, bone * (opacity * 0.74f), VisualStage >= 3 ? 3.8f : 2.5f);
        DrawRing(pixel, elbow, VisualStage >= 3 ? 13f : 8f, 12, fire * (opacity * 0.52f), 4f, weaponAngle);
        DrawRing(pixel, elbow, VisualStage >= 3 ? 8f : 5f, 10, bone * opacity, 2.2f, weaponAngle);

        int ribs = VisualStage >= 3 ? 5 : 3;
        for (int index = 0; index < ribs; index++)
        {
            float along = 0.2f + index * 0.115f;
            Vector2 spine = Vector2.Lerp(shoulder, elbow, along);
            float ribLength = (16f + VisualStage * 5f) * (1f - index * 0.07f);
            Vector2 side = normal * ribLength;
            Vector2 curled = direction * (7f + index * 1.5f);
            DrawVisualSegment(pixel, spine, spine + side, fire * (opacity * 0.34f), 5.4f);
            DrawVisualSegment(pixel, spine + side, spine + side * 0.76f + curled, bone * (opacity * 0.76f), 2.3f);
            DrawVisualSegment(pixel, spine, spine - side, fire * (opacity * 0.34f), 5.4f);
            DrawVisualSegment(pixel, spine - side, spine - side * 0.76f + curled, bone * (opacity * 0.76f), 2.3f);
        }

        if (VisualStage >= 3 && IsComboFinisher)
        {
            Vector2 focus = grip + aimRotation.ToRotationVector2() * 90f;
            DrawBoneGuillotine(pixel, focus, fade);
        }
    }

    private void DrawBoneGuillotine(Texture2D pixel, Vector2 center, float fade)
    {
        Color fire = new Color(25, 220, 255, 0);
        Color bone = new Color(250, 246, 215, 0);
        float pulse = Smooth01((animationProgress - 0.2f) / 0.24f) * fade;
        Vector2 vertical = Vector2.UnitY * 66f;
        Vector2 horizontal = Vector2.UnitX * 60f;
        DrawVisualSegment(pixel, center - vertical, center + vertical, fire * (pulse * 0.28f), 22f);
        DrawVisualSegment(pixel, center - vertical, center + vertical, bone * (pulse * 0.56f), 5f);
        DrawVisualSegment(pixel, center - horizontal, center + horizontal, fire * (pulse * 0.22f), 18f);
        DrawVisualSegment(pixel, center - horizontal, center + horizontal, bone * (pulse * 0.46f), 4f);
        DrawRing(pixel, center, 48f, 20, fire * (pulse * 0.25f), 4f, MathHelper.PiOver4);
    }

    private void DrawBloodMoonBackdrop(float fade)
    {
        bool showMoon = IsComboFinisher || snapshot.Form == ReaperFormId.Death;
        if (!showMoon)
            return;

        float grow = Smooth01((animationProgress - 0.08f) / 0.34f);
        float radius = (VisualStage >= 3 ? 92f : 66f) * grow;
        Vector2 center = grip + aimRotation.ToRotationVector2() * (VisualStage >= 3 ? 92f : 72f);
        if (radius > 2f)
        {
            DeathDomainPrimitiveTextureSystem.DrawRadialGradient(Main.spriteBatch,
                center - Main.screenPosition, radius * 1.28f,
                new Color(7, 0, 4, 145) * (fade * 0.72f));
            DeathDomainPrimitiveTextureSystem.DrawRadialGradient(Main.spriteBatch,
                center - Main.screenPosition, radius,
                new Color(92, 0, 18, 115) * (fade * 0.62f));
        }

        Texture2D pixel = TextureAssets.MagicPixel.Value;
        DrawRing(pixel, center, radius, 42, new Color(255, 22, 52, 0) * (fade * 0.32f),
            VisualStage >= 3 ? 7f : 4f, -Main.GlobalTimeWrappedHourly * 0.2f);
        if (VisualStage >= 3)
            DrawRing(pixel, center, radius * 0.76f, 36, new Color(255, 158, 128, 0) * (fade * 0.16f),
                2f, Main.GlobalTimeWrappedHourly * 0.3f);
    }

    private void DrawInfernalBackdrop(float fade)
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Vector2 center = grip + aimRotation.ToRotationVector2() * 62f;
        Color ember = new Color(255, 54, 4, 0);
        Color hot = new Color(255, 222, 70, 0);

        if (IsComboFinisher || snapshot.Form == ReaperFormId.Death)
        {
            float radius = VisualStage >= 3 ? 82f : 52f;
            float rotation = -MathHelper.PiOver2 + Main.GlobalTimeWrappedHourly * 0.28f * SwingDirection;
            DrawStarPolygon(pixel, center, radius, 5, rotation, ember * (fade * 0.26f), VisualStage >= 3 ? 8f : 5f);
            DrawStarPolygon(pixel, center, radius * 0.84f, 5, rotation, hot * (fade * 0.42f), 2.1f);
        }

        if (VisualStage < 3)
            return;

        Vector2 forward = weaponAngle.ToRotationVector2();
        Vector2 normal = forward.RotatedBy(MathHelper.PiOver2);
        Vector2 root = grip - forward * 15f;
        for (int side = -1; side <= 1; side += 2)
        {
            Vector2 previous = root;
            for (int feather = 0; feather < 4; feather++)
            {
                Vector2 end = root - forward * (28f + feather * 16f)
                    + normal * side * (32f + feather * 18f);
                DrawVisualSegment(pixel, previous, end, new Color(12, 2, 0, 205) * (fade * 0.72f), 15f - feather * 1.7f);
                DrawVisualSegment(pixel, previous, end, ember * (fade * (0.42f - feather * 0.045f)), 7f - feather * 0.7f);
                DrawVisualSegment(pixel, previous, end, hot * (fade * 0.36f), 1.8f);
                previous = Vector2.Lerp(root, end, 0.28f);
            }
        }
    }

    private void DrawInfernalMovementTrack(float fade)
    {
        if (!infernalMoveReady)
            return;

        Vector2 path = infernalMoveEnd - infernalMoveStart;
        float length = path.Length();
        if (length <= 1f)
            return;

        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Vector2 direction = path / length;
        Vector2 normal = direction.RotatedBy(MathHelper.PiOver2);
        float reveal = Smooth01((animationProgress - 0.04f) / 0.24f) * GetTerminalFade();
        float shellWidth = 13f + VisualStage * 3f;
        DrawVisualSegment(pixel, infernalMoveStart, infernalMoveEnd,
            new Color(8, 2, 0, 190) * (reveal * 0.58f), shellWidth);
        DrawVisualSegment(pixel, infernalMoveStart, infernalMoveEnd,
            new Color(255, 66, 5, 0) * (reveal * 0.48f), shellWidth * 0.54f);
        DrawVisualSegment(pixel, infernalMoveStart, infernalMoveEnd,
            new Color(255, 235, 130, 0) * (reveal * 0.72f), Math.Max(1.6f, shellWidth * 0.15f));

        int sparks = 3 + VisualStage * 2;
        for (int index = 0; index < sparks; index++)
        {
            float amount = (index + 0.5f) / sparks;
            Vector2 point = Vector2.Lerp(infernalMoveStart, infernalMoveEnd, amount);
            float side = (index & 1) == 0 ? 1f : -1f;
            Vector2 end = point - direction * (5f + index % 3 * 3f)
                + normal * side * (8f + VisualStage * 3f);
            DrawVisualSegment(pixel, point, end,
                new Color(255, 154, 35, 0) * (reveal * 0.42f), 1.6f);
        }

        if (IsComboFinisher)
        {
            DrawRing(pixel, infernalMoveEnd, 24f + VisualStage * 7f, 22,
                new Color(255, 76, 8, 0) * (reveal * 0.44f), 5f,
                Main.GlobalTimeWrappedHourly * 0.5f);
        }
    }

    private void DrawFrostMirrorBackdrop(float fade)
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Color glass = new Color(55, 155, 255, 50);
        Color edge = new Color(224, 255, 255, 0);
        int mirrors = VisualStage >= 3 ? 3 : 1;
        for (int index = 0; index < mirrors; index++)
        {
            float side = index - (mirrors - 1) * 0.5f;
            Vector2 focus = grip + aimRotation.ToRotationVector2() * (78f + index * 20f)
                + aimRotation.ToRotationVector2().RotatedBy(MathHelper.PiOver2) * side * 54f;
            float radius = VisualStage >= 3 ? 44f : 31f;
            DrawHexMirror(pixel, focus, radius, weaponAngle + index * 0.22f,
                glass * (fade * 0.58f), edge * (fade * 0.52f));
        }

        if (VisualStage >= 3 && IsComboFinisher)
        {
            Vector2 center = grip + aimRotation.ToRotationVector2() * 82f;
            float execution = Smooth01((animationProgress - 0.23f) / 0.23f) * fade;
            DrawHexMirror(pixel, center, 58f, 0f,
                new Color(38, 125, 225, 65) * execution,
                edge * (execution * 0.54f));
            DrawVisualSegment(pixel, center + new Vector2(-62f, -62f), center + new Vector2(62f, 62f),
                edge * (execution * 0.38f), 5.5f);
            DrawVisualSegment(pixel, center + new Vector2(-62f, 62f), center + new Vector2(62f, -62f),
                new Color(80, 205, 255, 0) * (execution * 0.44f), 7f);
        }
    }

    private void DrawSoulEchoBackdrop(float fade)
    {
        int echoes = Math.Clamp(VisualStage, 1, 3);
        for (int echo = echoes; echo >= 1; echo--)
        {
            int historyIndex = Math.Min(Math.Max(0, validHistorySamples - 1), echo * 3);
            float angle = validHistorySamples > 0 ? angleHistory[historyIndex] : weaponAngle;
            Vector2 offset = angle.ToRotationVector2().RotatedBy(-MathHelper.PiOver2) * (echo - 1) * 7f;
            float opacity = fade * (0.22f + (echoes - echo) * 0.08f);
            DrawSoulEchoFigure(grip + offset, angle, 0.82f + VisualStage * 0.12f, opacity);
        }

        if (VisualStage >= 3 && IsComboFinisher)
            DrawSoulEchoFigure(grip - new Vector2(0f, 18f), weaponAngle, 1.38f, fade * 0.30f);
    }

    private void DrawSoulEchoFigure(Vector2 center, float angle, float scale, float opacity)
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Color aura = new Color(104, 54, 255, 0) * opacity;
        Color spirit = new Color(80, 245, 255, 0) * (opacity * 0.82f);
        Vector2 head = center - Vector2.UnitY * 18f * scale;
        Vector2 bodyBottom = center + Vector2.UnitY * 29f * scale;
        DrawRing(pixel, head, 10f * scale, 16, aura, 5.5f * scale, angle);
        DrawVisualSegment(pixel, head + Vector2.UnitY * 5f * scale, bodyBottom, aura, 10f * scale);
        Vector2 hand = center + angle.ToRotationVector2() * 30f * scale;
        DrawVisualSegment(pixel, center - Vector2.UnitY * 2f * scale, hand, spirit, 3f * scale);
        DrawVisualSegment(pixel, hand - angle.ToRotationVector2() * 12f * scale,
            hand + angle.ToRotationVector2() * 48f * scale, aura, 4f * scale);
        Vector2 bladeEnd = hand + angle.ToRotationVector2() * 58f * scale
            + angle.ToRotationVector2().RotatedBy(MathHelper.PiOver2) * 18f * scale * SwingDirection;
        DrawVisualSegment(pixel, hand + angle.ToRotationVector2() * 43f * scale,
            bladeEnd, spirit, 3.2f * scale);
    }

    private void DrawTrail()
    {
        if (snapshot.Form == ReaperFormId.Death)
        {
            // The opaque domain face and its fixed-size cutting edge are emitted
            // together by DeathDomainCrescentVisualSystem. Drawing the old sprite
            // rim here would reintroduce a pulsing duplicate over that geometry.
            return;
        }
        if (VisualForm == ReaperFormId.Void)
        {
            DrawVoidCut();
            return;
        }
        DrawMaterialCrescentTrail();
        if (VisualForm == ReaperFormId.Base)
            DrawBaseTrail();
    }

    private void DrawMaterialCrescentTrail()
    {
        float fade = GetSwingVisibility();
        if (fade <= 0.001f)
            return;
        float weaponLength = ReaperCombatRegistry.GetBladeTipLength(
            snapshot.Form, snapshot.Stage);
        float radius = weaponLength * (VisualStage switch
        {
            <= 0 => 0.86f,
            1 => 0.94f,
            2 => 1.02f,
            _ => 1.10f
        });
        if (VisualStage >= 3 && IsComboFinisher)
            radius *= 1.035f;
        int direction = GetAngularDirection(0);
        int motionLayers = VisualStage switch { <= 1 => 1, 2 => 2, _ => 4 };
        Vector2 forward = aimRotation.ToRotationVector2();
        Vector2 normal = forward.RotatedBy(MathHelper.PiOver2);
        Vector2 center = grip + forward * radius * 0.075f
            - normal * direction * radius * 0.11f;
        float rotation = aimRotation
            + MathHelper.Lerp(-0.12f, 0.10f,
                SmoothStep(animationProgress)) * direction;
        ReaperCrescentPrimitiveTextureSystem.DrawFormCrescent(VisualForm,
            center, rotation, radius, fade * 0.90f, motionLayers, direction,
            Main.GlobalTimeWrappedHourly, animationProgress);
    }

    private void DrawDeathPrimaryTrail()
    {
        float visibility = GetDeathCrescentVisibility();
        if (visibility <= 0.001f)
            return;
        GetDeathPrimaryCrescentGeometry(out Vector2 center, out float rotation,
            out float reach, out int direction);
        ReaperCrescentPrimitiveTextureSystem.DrawDeathDomainCrescentRim(center,
            rotation,
            reach, visibility * 0.92f, 4, direction,
            Main.GlobalTimeWrappedHourly, animationProgress);
    }

    private void RecordDeathPrimaryCrescent()
    {
        float visibility = GetDeathCrescentVisibility();
        if (visibility <= 0.001f)
            return;
        if (deathCrescentVisualInstanceId <= 0)
        {
            deathCrescentVisualInstanceId = DeathDomainCrescentVisualSystem
                .AllocateVisualInstanceId();
        }
        GetDeathPrimaryCrescentGeometry(out Vector2 center, out float rotation,
            out float reach, out int direction);
        DeathDomainCrescentVisualSystem.Record(deathCrescentVisualInstanceId,
            Projectile.owner, center, rotation, reach, visibility * 0.92f,
            direction, animationProgress, DeathSpaceBreak);
    }

    private void GetDeathPrimaryCrescentGeometry(out Vector2 center,
        out float rotation, out float reach, out int direction)
    {
        direction = GetAngularDirection(0);
        reach = ReaperCombatRegistry.GetDeathPrimaryCrescentRadius(snapshot);
        center = grip;
        rotation = aimRotation;
    }

    private void SpawnDeathDomainRift()
    {
        // Max tempo only has six or seven animation frames.  Connecting those
        // sparse history samples turns a circular swing into several enormous
        // straight chords.  Reconstruct the exact immutable blade-tip locus at
        // high resolution from the same pose equation used by UpdateDeathPose.
        // Space Break represents the completed max-tempo sweep. Build the full
        // deterministic blade-tip locus instead of freezing whichever fractional
        // animation sample happened to cross the spawn threshold on this host.
        Vector2[] bladePath = BuildDeathDomainRiftPath(grip, aimRotation,
            SwingDirection, snapshot, 1f);
        ReaperDeathDomainRiftProjectile.Spawn(Projectile.GetSource_FromThis(),
            Projectile.owner, snapshot, bladePath, phase, Projectile.identity);
    }

    internal static Vector2[] BuildDeathDomainRiftPath(Vector2 pathGrip,
        float pathAimRotation, int pathSwingDirection,
        in SickleCombatSnapshot pathSnapshot, float pathAnimationProgress)
    {
        const int sampleCount = 65;
        Vector2[] bladePath = new Vector2[sampleCount];
        float traversed = SmoothStep(MathHelper.Clamp(pathAnimationProgress,
            0f, 1f));
        int direction = Math.Sign(pathSwingDirection) == 0
            ? 1 : Math.Sign(pathSwingDirection);
        float reach = ReaperCombatRegistry.GetDeathPrimaryBladeReach(
            pathSnapshot);
        for (int index = 0; index < sampleCount; index++)
        {
            float progress = index / (float)(sampleCount - 1) * traversed;
            float offset = MathHelper.Lerp(
                -ReaperCombatRegistry.DeathPrimaryHalfSweep,
                ReaperCombatRegistry.DeathPrimaryHalfSweep, progress)
                * direction;
            float angle = pathAimRotation + offset;
            bladePath[index] = pathGrip + angle.ToRotationVector2() * reach;
        }
        return bladePath;
    }

    private void DrawBaseTrail()
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        float fade = GetSwingVisibility();
        Color shadow = new(7, 45, 58, 150);
        Color spirit = new(45, 205, 225, 0);
        Color edge = new(215, 255, 250, 0);
        for (int index = validHistorySamples - 1; index > 0; index--)
        {
            float strength = GetHistoryStrength(index) * fade;
            Vector2 older = tipHistory[index];
            Vector2 newer = tipHistory[index - 1];
            float width = 3.2f + strength * 4.8f;
            DrawVisualSegment(pixel, older, newer,
                shadow * (strength * 0.72f), width * 1.75f);
            DrawVisualSegment(pixel, older, newer,
                spirit * (strength * 0.62f), width);
            DrawVisualSegment(pixel, older, newer,
                edge * (strength * 0.88f), 1.25f);

            // The inner filament is reconstructed from the same blade-tip
            // history, so the starter crescent grows with the real swing rather
            // than appearing at full size on its first frame.
            Vector2 innerOlder = Vector2.Lerp(grip, older, 0.78f);
            Vector2 innerNewer = Vector2.Lerp(grip, newer, 0.78f);
            DrawVisualSegment(pixel, innerOlder, innerNewer,
                spirit * (strength * 0.24f),
                Math.Max(1.4f, width * 0.42f));
        }
    }

    private void DrawBoneTrail()
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        float fade = GetTerminalFade();
        float scale = VisualStage switch { 1 => 1f, 2 => 1.05f, _ => 1.10f };
        Color corpseFire = new Color(35, 225, 255, 0);
        Color marrow = new Color(246, 244, 211, 0);
        Color shadow = new Color(8, 42, 54, 155);

        for (int index = validHistorySamples - 1; index > 0; index--)
        {
            Vector2 older = ScaleTip(index, scale);
            Vector2 newer = ScaleTip(index - 1, scale);
            Vector2 oldRadial = (older - grip).SafeNormalize(Vector2.UnitY);
            Vector2 newRadial = (newer - grip).SafeNormalize(Vector2.UnitY);
            older += oldRadial * (index % 2 == 0 ? 4f + VisualStage * 2f : -2f);
            newer += newRadial * ((index - 1) % 2 == 0 ? 4f + VisualStage * 2f : -2f);
            float strength = GetHistoryStrength(index) * fade;
            float width = (5.8f + VisualStage * 1.65f) * (0.35f + strength * 0.65f);
            DrawVisualSegment(pixel, older, newer, shadow * (strength * 0.82f), width * 2.05f);
            DrawVisualSegment(pixel, older, newer, corpseFire * (strength * 0.58f), width * 1.48f);
            DrawVisualSegment(pixel, older, newer, marrow * (strength * 0.92f), Math.Max(1.2f, width * 0.48f));

            if (index % 2 == 0)
            {
                Vector2 toothBase = Vector2.Lerp(older, newer, 0.5f);
                Vector2 inward = (grip - toothBase).SafeNormalize(-newRadial);
                float toothLength = 7f + VisualStage * 4f;
                DrawVisualSegment(pixel, toothBase, toothBase + inward * toothLength,
                    corpseFire * (strength * 0.38f), 4.8f);
                DrawVisualSegment(pixel, toothBase, toothBase + inward * toothLength,
                    marrow * (strength * 0.88f), 1.65f);
            }
        }
    }

    private void DrawBloodTrail()
    {
        float fade = GetSwingVisibility();
        bool apex = VisualStage >= 3 && (IsComboFinisher || snapshot.Form == ReaperFormId.Death);
        float weaponLength = ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage);
        float radius = weaponLength * (VisualStage switch { 1 => 0.94f, 2 => 1.02f, _ => 1.10f });
        if (apex)
            radius *= 1.035f;
        int motionLayers = VisualStage switch { 1 => 1, 2 => 2, _ => 4 };
        int motionDirection = SwingDirection * (phase % 2 == 0 ? 1 : -1);
        Vector2 forward = aimRotation.ToRotationVector2();
        Vector2 normal = forward.RotatedBy(MathHelper.PiOver2);
        Vector2 center = grip + forward * radius * 0.075f
            - normal * motionDirection * radius * 0.11f;
        float rotation = aimRotation
            + MathHelper.Lerp(-0.12f, 0.10f, SmoothStep(animationProgress)) * motionDirection;

        // A single high-resolution surface recreates the reference silhouette:
        // needle-point ends, a massive liquid-red face, warm-white outside edge
        // and layered motion exposure. No radial MagicPixel blocks remain.
        ReaperCrescentPrimitiveTextureSystem.DrawBloodCrescent(
            center,
            rotation,
            radius,
            fade * (apex ? 0.98f : 0.88f),
            motionLayers,
            motionDirection,
            Main.GlobalTimeWrappedHourly,
            animationProgress);
    }

    private void DrawInfernalTrail()
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        float fade = GetTerminalFade();
        float scale = VisualStage switch { 1 => 1f, 2 => 1.04f, _ => 1.09f };
        Color charred = new Color(7, 3, 2, 220);
        Color ember = new Color(255, 48, 4, 0);
        Color lava = new Color(255, 135, 12, 0);
        Color core = new Color(255, 246, 145, 0);

        for (int index = validHistorySamples - 1; index > 0; index--)
        {
            Vector2 older = ScaleTip(index, scale);
            Vector2 newer = ScaleTip(index - 1, scale);
            float strength = GetHistoryStrength(index) * fade;
            float width = (7f + VisualStage * 2.5f) * (0.32f + strength * 0.68f);
            DrawVisualSegment(pixel, older, newer, ember * (strength * 0.25f), width * 2.8f);
            DrawVisualSegment(pixel, older, newer, charred * (strength * 0.92f), width * 1.75f);
            DrawVisualSegment(pixel, older, newer, lava * (strength * 0.90f), width);
            DrawVisualSegment(pixel, older, newer, core * (strength * 0.86f), Math.Max(1.2f, width * 0.24f));

            if (VisualStage >= 2 && index % 4 == 0)
                DrawMoltenNode(Vector2.Lerp(older, newer, 0.5f), width * 0.72f, strength);
        }
    }

    private void DrawFrostTrail()
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        float fade = GetTerminalFade();
        float scale = VisualStage switch { 1 => 1f, 2 => 1.05f, _ => 1.10f };
        Color depth = new Color(12, 48, 105, 145);
        Color ice = new Color(55, 180, 255, 0);
        Color prism = new Color(226, 255, 255, 0);

        for (int index = validHistorySamples - 1; index > 0; index--)
        {
            Vector2 older = ScaleTip(index, scale);
            Vector2 newer = ScaleTip(index - 1, scale);
            float strength = GetHistoryStrength(index) * fade;
            float width = (7f + VisualStage * 2.2f) * (0.25f + strength * 0.75f);
            DrawVisualSegment(pixel, older, newer, depth * (strength * 0.74f), width * 2f);
            DrawVisualSegment(pixel, older, newer, ice * (strength * 0.62f), width);
            DrawVisualSegment(pixel, older, newer, prism * (strength * 0.92f), Math.Max(1.1f, width * 0.23f));

            if (index % 2 == 0)
            {
                Vector2 oldInner = grip + (older - grip) * (0.82f - VisualStage * 0.025f);
                Vector2 newInner = grip + (newer - grip) * (0.82f - VisualStage * 0.025f);
                DrawVisualSegment(pixel, older, newInner, prism * (strength * 0.34f), 1.15f);
                DrawVisualSegment(pixel, oldInner, newer, ice * (strength * 0.28f), 1.85f);
            }
        }
    }

    private void DrawSoulTrail()
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        float fade = GetTerminalFade();
        float scale = VisualStage switch { 1 => 1f, 2 => 1.05f, _ => 1.11f };
        Color deepSoul = new Color(48, 18, 118, 110);
        Color soul = new Color(130, 78, 255, 0);
        Color spirit = new Color(86, 245, 255, 0);
        float time = Main.GlobalTimeWrappedHourly * 7f;

        for (int index = validHistorySamples - 1; index > 0; index--)
        {
            Vector2 older = ScaleTip(index, scale);
            Vector2 newer = ScaleTip(index - 1, scale);
            Vector2 normal = (newer - older).SafeNormalize(Vector2.UnitX).RotatedBy(MathHelper.PiOver2);
            float strength = GetHistoryStrength(index) * fade;
            float wave = (float)Math.Sin(time + index * 0.9f) * (2f + VisualStage * 1.5f);
            float width = (8f + VisualStage * 3f) * (0.28f + strength * 0.72f);
            DrawVisualSegment(pixel, older, newer, deepSoul * (strength * 0.68f), width * 2.1f);
            DrawVisualSegment(pixel, older + normal * wave, newer + normal * wave,
                soul * (strength * 0.54f), width);
            DrawVisualSegment(pixel, older - normal * wave * 0.65f, newer - normal * wave * 0.65f,
                spirit * (strength * 0.46f), Math.Max(1.25f, width * 0.26f));
        }
    }

    private void DrawVoidCut()
    {
        // The client-only trail system records the real blade-tip path during AI
        // and keeps it for sixty frames. Nothing is drawn as a prebuilt crescent.
    }

    private void DrawDeathBindingTrail()
    {
        if (validHistorySamples < 2)
            return;

        Texture2D pixel = TextureAssets.MagicPixel.Value;
        float fade = GetTerminalFade();
        for (int index = validHistorySamples - 1; index > 0; index--)
        {
            float strength = GetHistoryStrength(index) * fade;
            DrawVisualSegment(pixel, tipHistory[index], tipHistory[index - 1],
                new Color(8, 0, 5, 210) * (strength * 0.86f), 13f * strength + 2f);
            DrawVisualSegment(pixel, tipHistory[index], tipHistory[index - 1],
                new Color(255, 20, 48, 0) * (strength * 0.54f), 2.1f);
        }
    }

    private void DrawWeaponAfterimages(Texture2D texture, float scale)
    {
        bool soulTrail = snapshot.Form == ReaperFormId.Soul;
        int copies = soulTrail ? 2 + VisualStage * 2
            : HasStageVisual(ReaperStage.StageIII) ? 4
            : HasStageVisual(ReaperStage.StageII) ? 2 : 0;
        if (copies <= 0 || validHistorySamples < 3)
            return;

        Color primary = ReaperCombatRegistry.GetPrimaryColor(snapshot.Form) with { A = 0 };
        Color secondary = ReaperCombatRegistry.GetSecondaryColor(snapshot.Form) with { A = 0 };
        float terminalFade = MathHelper.Clamp((0.985f - animationProgress) / 0.12f, 0f, 1f);
        for (int copy = copies; copy >= 1; copy--)
        {
            int historyIndex = Math.Min(validHistorySamples - 1,
                soulTrail ? copy : copy * 2);
            float strength = (copies - copy + 1f) / (copies + 1f) * terminalFade;
            float opacity = soulTrail ? 0.52f : 0.34f;
            Color color = Color.Lerp(primary, secondary,
                copy / (float)(copies + 1)) * (strength * opacity);
            DrawWeapon(texture, grip, angleHistory[historyIndex], color,
                scale * (soulTrail ? 1f : 0.96f + strength * 0.04f),
                GetWeaponEffects(historyIndex));
        }
    }

    private void DrawStageRunes()
    {
        if (validHistorySamples < 2)
            return;

        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Color primary = ReaperCombatRegistry.GetPrimaryColor(VisualForm) with { A = 0 };
        Color secondary = ReaperCombatRegistry.GetSecondaryColor(VisualForm) with { A = 0 };
        int runeCount = HasStageVisual(ReaperStage.StageIII) ? 5 : 3;
        float time = Main.GlobalTimeWrappedHourly;
        for (int rune = 0; rune < runeCount; rune++)
        {
            int historyIndex = 1 + rune * (HistoryLength - 4) / Math.Max(1, runeCount - 1);
            if (historyIndex >= validHistorySamples)
                continue;
            Vector2 position = tipHistory[Math.Clamp(historyIndex, 0, HistoryLength - 1)];
            if (position == Vector2.Zero || Vector2.DistanceSquared(position, grip) > 300f * 300f)
                continue;

            float historyStrength = 1f - historyIndex / (float)HistoryLength;
            float pulse = 0.78f + (float)Math.Sin(time * 5f + rune * 1.7f) * 0.16f;
            float size = (HasStageVisual(ReaperStage.StageIII) ? 7.5f : 6f) * (0.72f + historyStrength * 0.38f);
            float rotation = weaponAngle + rune * 0.37f + time * (rune % 2 == 0 ? 0.42f : -0.32f);
            DrawRuneGlyph(pixel, position, size, rotation, primary, secondary,
                pulse * (0.35f + historyStrength * 0.55f));
        }
    }

    private void DrawRuneGlyph(
        Texture2D pixel,
        Vector2 center,
        float size,
        float rotation,
        Color outer,
        Color core,
        float opacity)
    {
        Vector2 x = rotation.ToRotationVector2() * size;
        Vector2 y = x.RotatedBy(MathHelper.PiOver2);
        switch (VisualForm)
        {
            case ReaperFormId.Bone:
                DrawLayeredVisualSegment(pixel, center - x, center + x, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center - y * 0.72f, center + y * 0.72f, outer, core, opacity);
                break;
            case ReaperFormId.Blood:
                DrawLayeredVisualSegment(pixel, center - x, center + y, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center + y, center + x, outer, core, opacity);
                break;
            case ReaperFormId.Infernal:
                DrawLayeredVisualSegment(pixel, center - x, center + y, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center + y, center + x, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center + x, center - x, outer, core, opacity);
                break;
            case ReaperFormId.Frost:
                for (int axis = 0; axis < 3; axis++)
                {
                    Vector2 spoke = (rotation + axis * MathHelper.Pi / 3f).ToRotationVector2() * size;
                    DrawLayeredVisualSegment(pixel, center - spoke, center + spoke, outer, core, opacity);
                }
                break;
            case ReaperFormId.Soul:
                DrawLayeredVisualSegment(pixel, center - x, center + y, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center + y, center + x, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center + x, center - y, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center - y, center - x, outer, core, opacity);
                break;
            case ReaperFormId.Void:
                DrawLayeredVisualSegment(pixel, center - x - y * 0.35f, center + x - y * 0.35f, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center - x + y * 0.35f, center + x + y * 0.35f, outer, core, opacity * 0.72f);
                break;
            default:
                DrawLayeredVisualSegment(pixel, center - x, center + x, outer, core, opacity);
                DrawLayeredVisualSegment(pixel, center - y, center + y, outer, core, opacity);
                break;
        }
    }

    private void DrawMoltenNode(Vector2 center, float radius, float opacity)
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Color ember = new Color(255, 48, 4, 0) * (opacity * 0.52f);
        Color core = new Color(255, 239, 112, 0) * (opacity * 0.86f);
        DrawRing(pixel, center, radius, 10, ember, Math.Max(2f, radius * 0.42f), weaponAngle);
        for (int spoke = 0; spoke < 4; spoke++)
        {
            Vector2 direction = (weaponAngle + spoke * MathHelper.PiOver2).ToRotationVector2();
            DrawVisualSegment(pixel, center - direction * radius * 1.35f,
                center + direction * radius * 1.35f, ember, Math.Max(1.5f, radius * 0.25f));
            DrawVisualSegment(pixel, center - direction * radius * 0.82f,
                center + direction * radius * 0.82f, core, 1.25f);
        }
    }

    private void DrawVoidCoordinatePlane(Vector2 start, Vector2 end, Vector2 normal, float fade)
    {
        Texture2D pixel = TextureAssets.MagicPixel.Value;
        Vector2 direction = (end - start).SafeNormalize(Vector2.UnitX);
        Color grid = new Color(176, 42, 255, 0) * (fade * 0.30f);
        Color white = new Color(255, 240, 255, 0) * (fade * 0.48f);
        float length = Vector2.Distance(start, end);

        for (int tick = 1; tick <= 7; tick++)
        {
            float amount = tick / 8f;
            Vector2 center = Vector2.Lerp(start, end, amount);
            float half = 15f + (float)Math.Sin(amount * MathHelper.Pi) * 24f;
            DrawVisualSegment(pixel, center - normal * half, center + normal * half,
                tick == 4 ? white : grid, tick == 4 ? 2.3f : 1.2f);
        }

        DrawVisualSegment(pixel, start, end, grid, 1.15f);
        DrawVisualSegment(pixel, start - normal * 31f, end - normal * 31f, grid * 0.55f, 1.05f);
        for (int gate = 0; gate < 3; gate++)
        {
            float amount = 0.28f + gate * 0.29f;
            Vector2 center = start + direction * length * amount;
            float size = 17f + gate * 7f;
            Vector2 x = direction * size;
            Vector2 y = normal * size;
            DrawVisualSegment(pixel, center - x, center + y, grid, 1.75f);
            DrawVisualSegment(pixel, center + y, center + x, white * 0.62f, 1.35f);
            DrawVisualSegment(pixel, center + x, center - y, grid, 1.75f);
            DrawVisualSegment(pixel, center - y, center - x, white * 0.62f, 1.35f);
        }
    }

    private static void DrawHexMirror(
        Texture2D pixel,
        Vector2 center,
        float radius,
        float rotation,
        Color glass,
        Color edge)
    {
        Span<Vector2> vertices = stackalloc Vector2[6];
        for (int index = 0; index < vertices.Length; index++)
            vertices[index] = center + (rotation + index * MathHelper.TwoPi / 6f).ToRotationVector2() * radius;

        for (int index = 0; index < vertices.Length; index++)
        {
            Vector2 current = vertices[index];
            Vector2 next = vertices[(index + 1) % vertices.Length];
            DrawVisualSegment(pixel, current, next, edge, 2.2f);
            DrawVisualSegment(pixel, center, Vector2.Lerp(current, next, 0.5f), glass, radius * 0.20f);
        }
        DrawVisualSegment(pixel, vertices[0], vertices[3], edge * 0.38f, 1.15f);
        DrawVisualSegment(pixel, vertices[1], vertices[4], edge * 0.26f, 1f);
        DrawVisualSegment(pixel, vertices[2], vertices[5], edge * 0.26f, 1f);
    }

    private static void DrawStarPolygon(
        Texture2D pixel,
        Vector2 center,
        float radius,
        int points,
        float rotation,
        Color color,
        float width)
    {
        if (points < 3)
            return;

        Vector2 previous = center + rotation.ToRotationVector2() * radius;
        int currentIndex = 0;
        for (int edge = 0; edge < points; edge++)
        {
            currentIndex = (currentIndex + 2) % points;
            Vector2 current = center + (rotation + currentIndex * MathHelper.TwoPi / points).ToRotationVector2() * radius;
            DrawVisualSegment(pixel, previous, current, color, width);
            previous = current;
        }
    }

    private static void DrawRing(
        Texture2D pixel,
        Vector2 center,
        float radius,
        int segments,
        Color color,
        float width,
        float rotation = 0f)
    {
        if (radius <= 0.1f || segments < 3)
            return;

        segments = Math.Max(segments,
            Math.Min(160, (int)Math.Ceiling(MathHelper.TwoPi * radius / 18f)));

        Vector2 previous = center + rotation.ToRotationVector2() * radius;
        for (int index = 1; index <= segments; index++)
        {
            Vector2 current = center + (rotation + index * MathHelper.TwoPi / segments).ToRotationVector2() * radius;
            DrawVisualSegment(pixel, previous, current, color, width);
            previous = current;
        }
    }

    private void SpawnFormImpact(Vector2 center, ReaperFormId form)
    {
        int count = 4 + VisualStage * 2;
        int dustType = form switch
        {
            ReaperFormId.Bone => DustID.DungeonSpirit,
            ReaperFormId.Blood => DustID.Blood,
            ReaperFormId.Infernal => DustID.Torch,
            ReaperFormId.Frost => DustID.IceTorch,
            ReaperFormId.Soul => DustID.AncientLight,
            ReaperFormId.Void => DustID.Shadowflame,
            _ => DustID.AncientLight
        };
        Color color = ReaperCombatRegistry.GetPrimaryColor(form);
        Vector2 tangent = weaponAngle.ToRotationVector2().RotatedBy(MathHelper.PiOver2);
        for (int index = 0; index < count; index++)
        {
            Vector2 velocity = tangent * Main.rand.NextFloat(-4.2f, 4.2f)
                + weaponAngle.ToRotationVector2() * Main.rand.NextFloat(0.6f, 3.8f);
            Dust dust = Dust.NewDustPerfect(center + Main.rand.NextVector2Circular(11f, 11f),
                dustType, velocity, 60, color, Main.rand.NextFloat(0.72f, 1.18f));
            dust.noGravity = true;
        }

        if (form != ReaperFormId.Blood || VisualStage < 3)
            return;

        // Warm sparks at the contact point sell the white-hot edge of the giant
        // blood crescent without creating another damaging projectile.
        for (int index = 0; index < 12; index++)
        {
            Vector2 velocity = Main.rand.NextVector2CircularEdge(1f, 1f)
                * Main.rand.NextFloat(3.5f, 8.5f);
            Dust spark = Dust.NewDustPerfect(center, DustID.Torch, velocity, 20,
                index % 3 == 0 ? Color.White : new Color(255, 145, 74),
                Main.rand.NextFloat(0.72f, 1.16f));
            spark.noGravity = true;
        }
    }

    private void SpawnBasePrimaryImpact(Vector2 center)
    {
        Vector2 blade = weaponAngle.ToRotationVector2();
        Vector2 slash = blade.RotatedBy(MathHelper.PiOver2 * SwingDirection)
            .SafeNormalize(Vector2.UnitY);
        Vector2 towardOwner = (grip - center).SafeNormalize(-blade);

        ReaperVfxDirector.TriggerGlobalImpact(slash, 2.6f, 4,
            new Color(92, 214, 224), 0f, 0, 0.06f);
        SoundEngine.PlaySound(SoundID.Item71 with
        {
            Volume = 0.46f,
            Pitch = 0.24f,
            PitchVariance = 0.08f
        }, center);

        for (int index = 0; index < 9; index++)
        {
            Vector2 velocity = slash.RotatedBy(
                    Main.rand.NextFloat(-0.72f, 0.72f))
                * Main.rand.NextFloat(2.8f, 6.8f);
            Dust spark = Dust.NewDustPerfect(
                center + Main.rand.NextVector2Circular(7f, 7f),
                DustID.AncientLight, velocity, 30,
                index % 3 == 0 ? new Color(225, 255, 250)
                    : new Color(55, 220, 235),
                Main.rand.NextFloat(0.72f, 1.08f));
            spark.noGravity = true;
        }

        // A few harmless motes peel from the target and return toward the
        // wielder, preserving the basic weapon's soul-pulling identity.
        for (int index = 0; index < 4; index++)
        {
            Dust mote = Dust.NewDustPerfect(
                center + Main.rand.NextVector2Circular(9f, 9f),
                DustID.AncientLight,
                towardOwner.RotatedBy(Main.rand.NextFloat(-0.28f, 0.28f))
                    * Main.rand.NextFloat(1.2f, 2.8f),
                70, new Color(95, 230, 235),
                Main.rand.NextFloat(0.62f, 0.88f));
            mote.noGravity = true;
        }
    }

    private void TriggerPrimaryFinisherImpact()
    {
        Vector2 impactDirection = weaponAngle.ToRotationVector2()
            .RotatedBy(MathHelper.PiOver2 * SwingDirection);
        (float strength, int frames, Color color, float opacity, int colorFrames, float vignette) = VisualForm switch
        {
            ReaperFormId.Bone => (5.2f, 6, new Color(225, 255, 245), 0.20f, 4, 0.24f),
            ReaperFormId.Blood => (6.8f, 8, new Color(255, 238, 210), 0.34f, 5, 0.38f),
            ReaperFormId.Infernal => (6.2f, 7, new Color(255, 224, 148), 0.27f, 5, 0.27f),
            ReaperFormId.Frost => (5.5f, 7, new Color(224, 250, 255), 0.25f, 5, 0.24f),
            ReaperFormId.Soul => (5.2f, 7, new Color(160, 230, 255), 0.20f, 5, 0.28f),
            ReaperFormId.Void => (6.4f, 7, new Color(232, 185, 255), 0.29f, 4, 0.36f),
            _ => (4.6f, 5, Color.White, 0.16f, 3, 0.18f)
        };
        ReaperVfxDirector.TriggerGlobalImpact(impactDirection, strength, frames,
            color, opacity, colorFrames, vignette);
    }

    private Vector2 ScaleTip(int index, float scale)
    {
        index = Math.Clamp(index, 0, Math.Max(0, validHistorySamples - 1));
        return grip + (tipHistory[index] - grip) * scale;
    }

    private float GetHistoryStrength(int index)
    {
        return MathHelper.Clamp(1f - index / (float)HistoryLength, 0.08f, 1f);
    }

    private float GetTerminalFade()
    {
        return MathHelper.Clamp((0.985f - animationProgress) / 0.12f, 0f, 1f);
    }

    private float GetSwingVisibility()
    {
        return Smooth01(animationProgress / 0.13f) * GetTerminalFade();
    }

    private float GetDeathCrescentVisibility()
    {
        if (DeathSpaceBreak)
            return Smooth01(animationProgress / 0.08f);
        float terminalFade = 1f - Smooth01((animationProgress - 0.96f) / 0.04f);
        return Smooth01(animationProgress / 0.13f) * terminalFade;
    }

    private static void DrawLayeredVisualSegment(
        Texture2D pixel,
        Vector2 start,
        Vector2 end,
        Color outer,
        Color core,
        float opacity)
    {
        DrawVisualSegment(pixel, start, end, outer * (opacity * 0.58f), 3.6f);
        DrawVisualSegment(pixel, start, end, core * opacity, 1.15f);
    }

    private static void DrawVisualSegment(Texture2D pixel, Vector2 start, Vector2 end, Color color, float width)
    {
        DeathDomainPrimitiveTextureSystem.DrawSmoothWorldLine(start, end, color, width);
    }

    private bool HasStageVisual(ReaperStage minimumStage)
    {
        return snapshot.Form == ReaperFormId.Death
            || ReaperDefinitions.IsBranchForm(snapshot.Form) && snapshot.Stage >= minimumStage;
    }

    private void DrawWeapon(Texture2D texture, Vector2 position, float angle, Color color,
        float scale, SpriteEffects effects)
    {
        Vector2 normalizedAnchor = ReaperCombatRegistry.GetHandleAnchor(snapshot.Form, snapshot.Stage);
        Vector2 anchor = new(texture.Width * normalizedAnchor.X, texture.Height * normalizedAnchor.Y);
        if ((effects & SpriteEffects.FlipVertically) != 0)
            anchor.Y = texture.Height - anchor.Y;
        Main.EntitySpriteDraw(texture, position - Main.screenPosition, null, color,
            angle + ReaperCombatRegistry.GetTextureRotationCorrection(snapshot.Form, snapshot.Stage),
            anchor, scale, effects);
    }

    private SpriteEffects GetWeaponEffects(int historyIndex)
        => GetAngularDirection(historyIndex) < 0
            ? SpriteEffects.FlipVertically : SpriteEffects.None;

    private int GetAngularDirection(int historyIndex)
    {
        if (validHistorySamples >= 2)
        {
            int newer = Math.Clamp(historyIndex, 0, validHistorySamples - 1);
            int older = Math.Min(validHistorySamples - 1, newer + 1);
            float delta = MathHelper.WrapAngle(angleHistory[newer] - angleHistory[older]);
            if (Math.Abs(delta) > 0.001f)
                return delta >= 0f ? 1 : -1;
        }
        return SwingDirection;
    }

    private Vector2 GetBladeTip(Vector2 position, float angle) => position + angle.ToRotationVector2()
        * ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage)
        * (0.86f + snapshot.StageNumber * 0.045f);

    private float GetVoidVisibleReach()
        => ReaperCombatRegistry.GetBladeTipLength(snapshot.Form, snapshot.Stage)
            * (VisualStage switch { 1 => 1.02f, 2 => 1.07f, _ => 1.12f });

    private float GetVoidPrimaryWidth()
        => VisualStage switch { 1 => 9f, 2 => 13f, _ => 17f };

    private bool TryGetOwner(out Player player)
    {
        if (Projectile.owner < 0 || Projectile.owner >= Main.maxPlayers)
        {
            player = null!;
            return false;
        }
        player = Main.player[Projectile.owner];
        if (!player.active || player.dead)
            return false;

        // Once captured/validated, the swing belongs to its immutable snapshot.
        // Hotbar or form changes during the animation must not cancel it or make
        // later frames read properties from the newly held weapon. The server's
        // initial unconfigured path still requires the claimed source item.
        return configured
            || player.HeldItem.type == ItemType && player.HeldItem.ModItem is NormalSickle;
    }

    private static float SmoothStep(float value) => value * value * (3f - 2f * value);
    private static float Smooth01(float value)
    {
        value = MathHelper.Clamp(value, 0f, 1f);
        return value * value * (3f - 2f * value);
    }
    private static float EaseOutCubic(float value) => 1f - (float)Math.Pow(1f - value, 3f);
    private static float EaseOutSine(float value) => (float)Math.Sin(value * MathHelper.PiOver2);
}