ModernUO icon indicating copy to clipboard operation
ModernUO copied to clipboard

Fixes critical bug in MovementThrottle

Open jokker23 opened this issue 1 month ago • 14 comments

Problem

  • Reset condition now - nextMove + _movementThrottleReset > 0 almost always evaluates true when moving, constantly resetting the throttle
  • Credit system allows "speed banking" - players can walk slowly to bank 400ms for tactical speed bursts

Solution

Implements debt-based throttling:

  • Fixes the broken reset logic
  • Tracks early movement as accumulated debt
  • Prevents exploitation while preserving the intention of handling network jitter gracefully

Video Documentation

  • Before https://github.com/user-attachments/assets/291b1d2c-f73c-44bc-8570-15381f1e3224

  • After https://github.com/user-attachments/assets/1eab1c1f-37c6-4de4-9d5c-830be4b23120

jokker23 avatar Nov 12 '25 00:11 jokker23

Thank you for finding this bug! I am weary of using the ConditionalWeakTable due to performance concerns. Do you mind testing this potential solution and seeing if we can fix it without the complex logic/table?

public static class MovementThrottle
{
    private static long _maxCreditThreshold; // 400 milliseconds

    public static void Configure()
    {
        _maxCreditThreshold = ServerConfiguration.GetOrUpdateSetting("movement.throttleThreshold", 400);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var credit = ns._movementCredit;
        var nextMove = ns._nextMovementTime;
        var delta = now - nextMove;
        long cost = 0;

        // Reset system if idle for more than 1 second
        if (delta > 0)
        {
            credit = Math.Min(_maxCreditThreshold, credit + delta);
            ns._nextMovementTime = now;
        }
        else
        {
            cost = -delta;
        }

        if (credit < cost)
        {
            // Not enough credit, therefore throttled
            return true;
        }

        ns._movementCredit = credit - cost;
        return false;
    }
}

kamronbatman avatar Nov 12 '25 03:11 kamronbatman

My suggestion:

public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        long now = Core.TickCount;
        long lastMoveTime = ns._nextMovementTime;

        if (now - lastMoveTime > _movementThrottleReset)
        {
            ns._nextMovementTime = now;
            return false;
        }

        int expectedDelay = from.GetMovementDelay();
        
        if (now - lastMoveTime < expectedDelay)
        {
            return true;
        }

        ns._nextMovementTime = now;
        return false;
    }

Adds to Mobile.cs on Line 4372:

public virtual int GetMovementDelay() => ComputeMovementSpeed(Direction);

Bohicatv avatar Nov 12 '25 07:11 Bohicatv

My suggestion:

public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        long now = Core.TickCount;
        long lastMoveTime = ns._nextMovementTime;

        if (now - lastMoveTime > _movementThrottleReset)
        {
            ns._nextMovementTime = now;
            return false;
        }

        int expectedDelay = from.GetMovementDelay();
        
        if (now - lastMoveTime < expectedDelay)
        {
            return true;
        }

        ns._nextMovementTime = now;
        return false;
    }

Adds to Mobile.cs on Line 4372: public virtual int GetMovementDelay() => ComputeMovementSpeed(Direction);

This seems fine on the surface, but I think it only throttles 1 packet and then resets?

kamronbatman avatar Nov 12 '25 09:11 kamronbatman

My suggestion:

public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        long now = Core.TickCount;
        long lastMoveTime = ns._nextMovementTime;

        if (now - lastMoveTime > _movementThrottleReset)
        {
            ns._nextMovementTime = now;
            return false;
        }

        int expectedDelay = from.GetMovementDelay();
        
        if (now - lastMoveTime < expectedDelay)
        {
            return true;
        }

        ns._nextMovementTime = now;
        return false;
    }

Adds to Mobile.cs on Line 4372: public virtual int GetMovementDelay() => ComputeMovementSpeed(Direction);

This seems fine on the surface, but I think it only throttles 1 packet and then resets?

It's continuous throttling until legitimate timing is achieved. The reset only happens after a valid movement delay has elapsed (200ms). So the speed offender floods their own connection/client with rejected packets (dos). Only 1 in like 20-40 packets actually get processed here by the server (depending on the offender's modified speed). On a 200ms packet swing, I don't think this level of packet rejection will effect even hurt the most potato of hardware.

Lets say the speed offender sends 100 movement packets from speed increase modification. Only about 3 to 7 get processed. In theory, other players would see normal movement and no rubber banding from the offender.

I think its a solid approach, but needs verification from testing. This should make speed hacking completely pointless.

Bohicatv avatar Nov 12 '25 09:11 Bohicatv

Did some testing. It does cause movement lock ups for non-offenders, so if we went down this path, more testing/tweaking is needed. For this sort of movement approach, I don't know of any other way that is a more simpler way to fix this problem, but gave it a shot. The only other solution is to just disconnect the client/user on the first movement speed offense or have a threshold counter. ¯_(ツ)_/¯

Bohicatv avatar Nov 12 '25 11:11 Bohicatv

Thank you for finding this bug! I am weary of using the ConditionalWeakTable due to performance concerns. Do you mind testing this potential solution and seeing if we can fix it without the complex logic/table?

public static class MovementThrottle
{
    private static long _maxCreditThreshold; // 400 milliseconds

    public static void Configure()
    {
        _maxCreditThreshold = ServerConfiguration.GetOrUpdateSetting("movement.throttleThreshold", 400);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var credit = ns._movementCredit;
        var nextMove = ns._nextMovementTime;
        var delta = now - nextMove;
        long cost = 0;

        // Reset system if idle for more than 1 second
        if (delta > 0)
        {
            credit = Math.Min(_maxCreditThreshold, credit + delta);
            ns._nextMovementTime = now;
        }
        else
        {
            cost = -delta;
        }

        if (credit < cost)
        {
            // Not enough credit, therefore throttled
            return true;
        }

        ns._movementCredit = credit - cost;
        return false;
    }
}

I found jitter/timing vairance issues here that lead to gradual depletion on normal play. With the credit method I found a different type of exploit where one could bank up and then burst.

Video (i didnt make the speed hack pretty this time lol, watch the bottom one): https://github.com/user-attachments/assets/0eed389b-bafb-4dc6-9263-b3ab67384d1c

Also isnt the delta >0 moving late?

jokker23 avatar Nov 12 '25 23:11 jokker23

My suggestion:

public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        long now = Core.TickCount;
        long lastMoveTime = ns._nextMovementTime;

        if (now - lastMoveTime > _movementThrottleReset)
        {
            ns._nextMovementTime = now;
            return false;
        }

        int expectedDelay = from.GetMovementDelay();
        
        if (now - lastMoveTime < expectedDelay)
        {
            return true;
        }

        ns._nextMovementTime = now;
        return false;
    }

Adds to Mobile.cs on Line 4372: public virtual int GetMovementDelay() => ComputeMovementSpeed(Direction);

This is an interesting approach but _nextMovementTime gets very confusing.

jokker23 avatar Nov 12 '25 23:11 jokker23

What do you guys think of something like this:

public static class MovementThrottle
{
    private static long _maxJitter;
    private static long _maxTimeDebt;
    private static long _debtDecayTime;
    private static long _debtDecayAmount;
    private static long _idleResetThreshold;

    public static void Configure()
    {
        _maxJitter = ServerConfiguration.GetOrUpdateSetting("movement.maxJitter", 50);
        _maxTimeDebt = ServerConfiguration.GetOrUpdateSetting("movement.maxTimeDebt", 100);
        _debtDecayTime = ServerConfiguration.GetOrUpdateSetting("movement.debtDecayTime", 500);
        _debtDecayAmount = ServerConfiguration.GetOrUpdateSetting("movement.debtDecayAmount", 100);
        _idleResetThreshold = ServerConfiguration.GetOrUpdateSetting("movement.idleResetThreshold", 2000);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;
        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        long now = Core.TickCount;
        long nextMove = ns._nextMovementTime;
        long debt = ns._movementCredit;
        long lastDecay = ns._throttleLastAllow;

        if (lastDecay == 0)
        {
            ns._throttleLastAllow = now;
            ns._movementCredit = 0;
            return false;
        }

        long delta = now - nextMove;

        if (nextMove == 0 || delta > _idleResetThreshold)
        {
            long oldDebt = debt;
            
            if (delta > _idleResetThreshold)
            {
                long idleSeconds = Math.Min(60, delta / 1000);
                debt = Math.Max(0, debt - (idleSeconds * 50));                
            }
            
            ns._nextMovementTime = now;
            ns._throttleLastAllow = now;
            ns._movementCredit = debt;

            return false;
        }

        long timeSinceDecay = now - lastDecay;
        if (timeSinceDecay >= _debtDecayTime)
        {
            long periods = timeSinceDecay / _debtDecayTime;
            long oldDebt = debt;
            debt = Math.Max(0, debt - (periods * _debtDecayAmount));
            ns._throttleLastAllow += periods * _debtDecayTime;
        }


        if (delta >= 0)
        {
            long oldDebt = debt;            
            debt = Math.Max(0, debt - delta);
            ns._movementCredit = debt;            
            return false;
        }
        
        long early = -delta;
        if (early > _maxJitter)
        {
            long oldDebt = debt;
            long added = early - _maxJitter;
            debt += added;
            debt = Math.Min(1000, debt);

            ns._movementCredit = debt;
            if (debt > _maxTimeDebt)
            {
                return true;
            }
        }
        else
        {            
            ns._movementCredit = debt;
        }        

        return false;
    }
}

jokker23 avatar Nov 12 '25 23:11 jokker23

Let's also test a combination of a credit/debt system, something like this:

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 75);
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 240);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        var credit = ns._movementCredit;

        // Calculate time delta: positive = packet arrived early, negative = arrived late
        var delta = nextMove - now;

        if (delta > 0)
        {
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            credit -= -delta;

            if (credit < -_maxDebtThreshold)
            {
                return true; // Throttle - player is moving too fast
            }
        }

        ns._movementCredit = credit;
        return false;
    }
}

kamronbatman avatar Nov 13 '25 01:11 kamronbatman

Let's also test a combination of a credit/debt system, something like this:

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 75);
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 240);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        var credit = ns._movementCredit;

        // Calculate time delta: positive = packet arrived early, negative = arrived late
        var delta = nextMove - now;

        if (delta > 0)
        {
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            credit -= -delta;

            if (credit < -_maxDebtThreshold)
            {
                return true; // Throttle - player is moving too fast
            }
        }

        ns._movementCredit = credit;
        return false;
    }
}

I like this, but credits don't get reset and can go very negative. However, lets disconnect the client if they get too negative.

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;
    private static long _disconnectDebtThreshold;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 75);
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 240);
        _disconnectDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.disconnectDebtThreshold", 500);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        var credit = ns._movementCredit;

        // Calculate time delta: positive = packet arrived early, negative = arrived late
        var delta = nextMove - now;

        if (delta > 0)
        {
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            credit -= -delta;

            if (credit < -_maxDebtThreshold)
            {
                if (credit < -_disconnectDebtThreshold)
                {
                    ns.Disconnect("You were disconnected for speed violations.");
                    return true;
                }

                return true; // Throttle - player is moving too fast
            }
        }

        ns._movementCredit = credit;
        return false;
    }
}

Bohicatv avatar Nov 13 '25 11:11 Bohicatv

Updated to account for stable/unstable connection lag (debt forgiveness)

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;
    private static long _disconnectDebtThreshold;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 75);
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 240);
        _disconnectDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.disconnectDebtThreshold", 500);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        var credit = ns._movementCredit;

        if (now - nextMove > 2000)
        {
            credit = Math.Max(0, credit);
        }

        // Calculate time delta: positive = packet arrived early, negative = arrived late
        var delta = nextMove - now;

        if (delta > 0)
        {
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            credit -= -delta;

            if (credit < -_maxDebtThreshold)
            {
                if (credit < -_disconnectDebtThreshold)
                {
                    ns.Disconnect("You were disconnected for speed violations.");
                    return true;
                }

                return true; // Throttle - player is moving too fast
            }
        }

        ns._movementCredit = credit;
        return false;
    }
}

Bohicatv avatar Nov 13 '25 12:11 Bohicatv

Let's also test a combination of a credit/debt system, something like this:

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 75);
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 240);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        var credit = ns._movementCredit;

        // Calculate time delta: positive = packet arrived early, negative = arrived late
        var delta = nextMove - now;

        if (delta > 0)
        {
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            credit -= -delta;

            if (credit < -_maxDebtThreshold)
            {
                return true; // Throttle - player is moving too fast
            }
        }

        ns._movementCredit = credit;
        return false;
    }
}

I like this, but credits don't get reset and can go very negative. However, lets disconnect the client if they get too negative.

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;
    private static long _disconnectDebtThreshold;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 75);
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 240);
        _disconnectDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.disconnectDebtThreshold", 500);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;

        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        var credit = ns._movementCredit;

        // Calculate time delta: positive = packet arrived early, negative = arrived late
        var delta = nextMove - now;

        if (delta > 0)
        {
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            credit -= -delta;

            if (credit < -_maxDebtThreshold)
            {
                if (credit < -_disconnectDebtThreshold)
                {
                    ns.Disconnect("You were disconnected for speed violations.");
                    return true;
                }

                return true; // Throttle - player is moving too fast
            }
        }

        ns._movementCredit = credit;
        return false;
    }
}

I think I got the logic reversed, I'll fix that.

kamronbatman avatar Nov 13 '25 15:11 kamronbatman

public static class MovementThrottle
{
    private static long _maxCreditBuffer;
    private static long _maxDebtThreshold;
    private static long _disconnectDebtThreshold;
    private static long _jitterTolerance;           // ADDED: Handle natural packet timing variance
    private static long _idleResetThreshold;        // ADDED: Detect true idle to prevent timing issues
    
    public static void Configure()
    {
        // CHANGED: Increased from 75ms to 300ms - provides buffer for lag spikes and variance recovery
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 300);
        
        // CHANGED: Increased from 240ms to 600ms - prevents false positives from variance stacking
        _maxDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtThreshold", 600);
        
        // CHANGED: Increased from 500ms to 3000ms - only disconnect persistent cheaters, not lag victims
        _disconnectDebtThreshold = ServerConfiguration.GetOrUpdateSetting("movement.disconnectDebtThreshold", 3000);
        
        // ADDED: 120ms tolerance handles ±10% client variance plus typical network jitter
        // Without this, normal variance would accumulate debt unfairly
        _jitterTolerance = ServerConfiguration.GetOrUpdateSetting("movement.jitterTolerance", 120);
        
        // ADDED: 2 second threshold to detect idle and reset timing
        _idleResetThreshold = ServerConfiguration.GetOrUpdateSetting("movement.idleResetThreshold", 2000);
    }
    
    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }
    
    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;
        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }
        
        var now = Core.TickCount;
        var nextMove = ns._nextMovementTime;
        
        // ADDED: Initialize on first packet - prevents reading garbage values
        // Must check BEFORE reading credit to avoid uninitialized memory
        if (nextMove == 0)
        {
            ns._nextMovementTime = now;
            ns._movementCredit = _maxCreditBuffer;
            return false;
        }
        
        var credit = ns._movementCredit;
        var delta = now - nextMove;  // positive = late, negative = early
        
        // ADDED: Handle idle reset - fixes timing after extended pauses
        // Without this, _nextMovementTime stays in past forever causing infinite credit
        if (delta > _idleResetThreshold)
        {
            ns._nextMovementTime = now;  // Reset timing to present (Mobile.Move will add proper delay)
            ns._movementCredit = _maxCreditBuffer;
            return false;
        }
        
        if (delta >= 0)
        {
            // Late/on-time: restore credit up to buffer cap
            credit = Math.Min(_maxCreditBuffer, credit + delta);
        }
        else
        {
            // Early movement: apply jitter tolerance before consuming credit
            var early = -delta;
            
            // ADDED: Jitter tolerance - only penalize movement beyond expected variance
            // Critical for handling ±10% client timing variance plus network latency
            var effectiveEarly = Math.Max(0, early - _jitterTolerance);
            credit -= effectiveEarly;
            
            if (credit < -_maxDebtThreshold)
            {
                // Save credit BEFORE returning
                // Without this, debt never accumulates past first threshold
                ns._movementCredit = credit;
                
                if (credit < -_disconnectDebtThreshold)
                {
                    ns.Disconnect("You were disconnected for speed violations.");
                    return true;
                }
                return true;
            }
        }
        
        ns._movementCredit = credit;
        return false;
    }
}

jokker23 avatar Nov 13 '25 18:11 jokker23

How about a hybrid approach?

    private const long _idleResetThreshold = 800;
    private const long _suspiciousLogCooldown = 60000; // 1 minute between logs per player

    private static readonly ILogger logger = LogFactory.GetLogger(typeof(MovementThrottle));
    
    private static long _maxCreditBuffer = 30;
    private static long _maxDebtBuffer = 30;
    private static int _consecutiveThrottleThreshold = 10;

    public static void Configure()
    {
        _maxCreditBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxCreditBuffer", 30);
        _maxDebtBuffer = ServerConfiguration.GetOrUpdateSetting("movement.maxDebtBuffer", 30);
        _consecutiveThrottleThreshold = ServerConfiguration.GetOrUpdateSetting("movement.consecutiveThrottleThreshold", 10);
    }

    public static unsafe void Initialize()
    {
        IncomingPackets.RegisterThrottler(0x02, &Throttle);
    }

    public static bool Throttle(int packetId, NetState ns)
    {
        var from = ns.Mobile;
        if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player)
        {
            return false;
        }

        var now = Core.TickCount;
        var timeSinceLastMove = now - ns._lastMovementTime;

        // Reset on idle
        if (timeSinceLastMove >= _idleResetThreshold)
        {
            ns._movementCredit = 0;
            ns._movementDebt = 0;
            ns._consecutiveThrottles = 0;
            ns._lastMovementTime = now;
            return false;
        }

        var nextMove = ns._nextMovementTime;
        var deficit = Math.Max(0, nextMove - now);
        var credit = ns._movementCredit;
        var debt = ns._movementDebt;

        // Pay off debt first
        if (debt > 0)
        {
            var payoff = Math.Min(debt, timeSinceLastMove);
            timeSinceLastMove -= payoff;
            debt -= payoff;
            ns._movementDebt = debt;
        }

        if (debt <= 0 && timeSinceLastMove > 0)
        {
            // Accumulate credit when late (no debt, or debt paid off)
            credit = Math.Min(_maxCreditBuffer, credit + timeSinceLastMove);
        }

        // Check if can afford movement
        if (deficit > credit)
        {
            // Try to go into debt
            var newDebt = deficit - credit;
            if (newDebt > _maxDebtBuffer)
            {
                // Too much debt, throttle
                if (++ns._consecutiveThrottles >= _consecutiveThrottleThreshold)
                {
                    LogSuspiciousActivity(ns, deficit, credit, debt, newDebt);
                }
                return true;
            }
            ns._movementDebt = newDebt;
            ns._movementCredit = 0;
        }
        else
        {
            ns._movementCredit = credit - deficit;
        }

        // Reset throttle counter on successful move
        ns._consecutiveThrottles = 0;
        ns._lastMovementTime = now;
        return false;
    }

    private static void LogSuspiciousActivity(NetState ns, long deficit, long credit, long debt, long attemptedDebt)
    {
        // Rate limit logging per player
        var now = Core.TickCount;
        if (now - ns._lastSuspiciousActivityLog < _suspiciousLogCooldown)
        {
            return;
        }

        ns._lastSuspiciousActivityLog = now;

        var from = ns.Mobile;
        logger.Warning(
            "Potential speed hack detected: {Account} ({Character}) | " +
            "Deficit: {Deficit}ms | Credit: {Credit}ms | Debt: {Debt}ms | " +
            "Attempted Debt: {AttemptedDebt}ms | Consecutive Throttles: {Count}",
            ns.Account?.Username ?? "Unknown",
            from?.Name ?? "Unknown",
            deficit,
            credit,
            debt,
            attemptedDebt,
            ns._consecutiveThrottles
        );
    }
    ```

kamronbatman avatar Nov 15 '25 17:11 kamronbatman