Fixes critical bug in MovementThrottle
Problem
- Reset condition
now - nextMove + _movementThrottleReset > 0almost 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
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;
}
}
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);
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?
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.
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. ¯_(ツ)_/¯
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?
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.
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;
}
}
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;
}
}
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;
}
}
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;
}
}
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.
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;
}
}
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
);
}
```