feat: Adds robust speed hack detection and movement throttling (#2266)
## Summary
Server-side movement throttle that prevents speed hacking while accurately identifying cheaters with detection of lagging connections.
**Key features:**
- Credit buffer (200ms) absorbs timing jitter from legitimate players
- Movement queue handles larger bursts, draining at proper game-tick intervals
- RTT measurement distinguishes network lag from speed hacks
- Queue depth detection catches ACK-throttled speed hacks (going straight)
## How It Works
**Throttle** (prevention): Movements arriving too early either consume credit or get queued. The queue drains at
correct intervals, so speed hackers can't move faster regardless of what they send.
**Detection** (identification): Combines multiple signals to identify cheaters:
| Signal | What it catches |
|--------|-----------------|
| Queue depth ≥4 sustained | ACK-throttled speed hacks (client limits unacked moves to 5) |
| Movement rate >1.05x | Direction-change speed hacks where timing is visible |
| Stable RTT + high queue | Eliminates false positives from laggy players |
**RTT-Aware Logic:**
- Probes only sent to players actively moving (event-driven, not global loop)
- Stable low-latency + problems = suspicious
- Unstable/high-latency + problems = probably just lag, throttle handles it
## Configuration
```json
{
"movementThrottle.maxCredit": 200,
"movementThrottle.softQueueLimit": 6,
"movementThrottle.hardQueueLimit": 10,
"movementThrottle.debugLogging": false
}
```
This commit is contained in:
parent
6745cf2075
commit
04d438239d
17 changed files with 2474 additions and 74 deletions
File diff suppressed because it is too large
Load diff
389
Projects/Server/Network/NetState/NetState.Movement.cs
Normal file
389
Projects/Server/Network/NetState/NetState.Movement.cs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/*************************************************************************
|
||||
* ModernUO *
|
||||
* Copyright 2019-2026 - ModernUO Development Team *
|
||||
* Email: hi@modernuo.com *
|
||||
* File: NetState.Movement.cs *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
*************************************************************************/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Server.Logging;
|
||||
|
||||
namespace Server.Network;
|
||||
|
||||
public partial class NetState
|
||||
{
|
||||
private static readonly ILogger movementLogger = LogFactory.GetLogger(typeof(NetState));
|
||||
|
||||
// Per-connection movement logging (RTT instrumentation, etc.)
|
||||
// Can be enabled at runtime for specific connections
|
||||
internal bool _movementLogging;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether movement/RTT logging is enabled for this connection.
|
||||
/// When enabled, logs detailed RTT probe and response timing.
|
||||
/// </summary>
|
||||
public bool MovementLogging
|
||||
{
|
||||
get => _movementLogging;
|
||||
set => _movementLogging = value;
|
||||
}
|
||||
|
||||
internal struct QueuedMovement
|
||||
{
|
||||
public Direction Direction;
|
||||
public int Sequence;
|
||||
}
|
||||
|
||||
// Movement history record for rate calculation (8 bytes, cache-aligned)
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 2)]
|
||||
internal struct MovementRecord
|
||||
{
|
||||
public short Interval; // Time since previous packet (ms), capped at 32767
|
||||
public ushort TargetSpeed; // Expected interval (100ms mounted running, etc.)
|
||||
public byte QueueDepth; // Queue size when received
|
||||
public byte Flags; // MovementRecordFlags
|
||||
public short Reserved; // Padding to 8 bytes for cache alignment
|
||||
}
|
||||
|
||||
[Flags]
|
||||
internal enum MovementRecordFlags : byte
|
||||
{
|
||||
None = 0,
|
||||
Running = 1,
|
||||
Mounted = 2,
|
||||
DirectionChangeOnly = 4, // Cost was 0 (turn in place)
|
||||
WasQueued = 8 // Packet was queued, not executed immediately
|
||||
}
|
||||
|
||||
// Movement queue state
|
||||
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
|
||||
internal long _movementCredit; // Credit buffer for timing jitter
|
||||
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
|
||||
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
|
||||
internal long _lastQueueDepthCheck; // Throttle depth check frequency
|
||||
internal bool _hasQueuedMovements; // Fast check for Slice()
|
||||
|
||||
// Movement history for rate-based speed hack detection (lazy initialized)
|
||||
internal MovementRecord[] _movementHistory; // Circular buffer
|
||||
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
|
||||
internal bool _movementHistoryFull; // True once buffer has wrapped
|
||||
internal long _lastMovementRecordTime; // For calculating intervals
|
||||
|
||||
// Detection state
|
||||
internal int _consecutiveHighRateSeconds; // Sustained detection counter
|
||||
internal long _lastSpeedHackNotification; // Rate-limit notifications
|
||||
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
|
||||
|
||||
// Movement packet rate tracking (for speed hack detection)
|
||||
internal long _movementWindowStart; // Start of current 1-second window
|
||||
internal int _movementsInWindow; // Count in current window
|
||||
internal int _peakMovementRate; // Highest rate seen (packets/sec)
|
||||
|
||||
/// <summary>
|
||||
/// Resets movement state when sequence needs to be cleared (paralysis, teleport, map change, etc.)
|
||||
/// </summary>
|
||||
public void ResetMovementState()
|
||||
{
|
||||
_movementQueue?.Clear();
|
||||
Sequence = 0;
|
||||
_nextMovementTime = Core.TickCount;
|
||||
_movementCredit = 0;
|
||||
_hasQueuedMovements = false;
|
||||
_sustainedQueueDepth = 0;
|
||||
|
||||
// Reset movement history - next movement starts a new chain
|
||||
_lastMovementRecordTime = 0;
|
||||
_movementHistoryIndex = 0;
|
||||
_movementHistoryFull = false;
|
||||
|
||||
// Reset detection state - sustained detection loses context on teleport/map change
|
||||
_consecutiveHighRateSeconds = 0;
|
||||
_lastGapDuration = 0;
|
||||
_rttProbeInterval = RttProbeIntervalNormal;
|
||||
|
||||
// Reset packet rate window
|
||||
_movementWindowStart = 0;
|
||||
_movementsInWindow = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks movement packet rate. Called for each movement packet received.
|
||||
/// Returns the current rate (packets per second in the last window).
|
||||
/// </summary>
|
||||
public int TrackMovementRate()
|
||||
{
|
||||
var now = Core.TickCount;
|
||||
|
||||
// Check if we're in a new 1-second window
|
||||
if (now - _movementWindowStart >= 1000)
|
||||
{
|
||||
// Record peak rate if this window had movements
|
||||
if (_movementsInWindow > _peakMovementRate)
|
||||
{
|
||||
_peakMovementRate = _movementsInWindow;
|
||||
}
|
||||
|
||||
// Start new window
|
||||
_movementWindowStart = now;
|
||||
_movementsInWindow = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Same window, increment count
|
||||
_movementsInWindow++;
|
||||
return _movementsInWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current movement rate (packets in the current 1-second window).
|
||||
/// </summary>
|
||||
public int CurrentMovementRate => _movementsInWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the peak movement rate observed for this session.
|
||||
/// </summary>
|
||||
public int PeakMovementRate => _peakMovementRate;
|
||||
|
||||
// RTT Measurement Configuration
|
||||
private const int RttProbeIntervalNormal = 5000; // Normal: probe every 5 seconds
|
||||
private const int RttProbeIntervalSuspicious = 2000; // Suspicious: probe every 2 seconds
|
||||
private const int RttProbeIntervalDefinite = 1000; // Definite cheater: probe every 1 second
|
||||
private const int RttProbeJitter = 500; // Random jitter to prevent bursts
|
||||
private const int RttHistorySize = 8; // Keep 8 samples
|
||||
private const long StableVarianceThreshold = 2500; // Variance < 50ms std dev = stable
|
||||
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
|
||||
|
||||
// RTT state
|
||||
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
|
||||
internal long _lastRtt; // Most recent RTT measurement
|
||||
internal long[] _rttHistory; // Rolling history (lazy init)
|
||||
internal int _rttHistoryIndex; // Current position in history
|
||||
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
|
||||
internal long _rttVariance; // Calculated variance for stability
|
||||
internal long _nextRttProbe; // When to send next probe
|
||||
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
|
||||
|
||||
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
|
||||
private long _rttProbeTimestampHiRes;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the RTT probe interval based on suspicion level.
|
||||
/// More suspicious = more frequent probes for better evidence.
|
||||
/// </summary>
|
||||
public void SetProbeFrequency(int suspicionLevel)
|
||||
{
|
||||
_rttProbeInterval = suspicionLevel switch
|
||||
{
|
||||
>= 3 => RttProbeIntervalDefinite, // Definite cheater
|
||||
>= 2 => RttProbeIntervalSuspicious, // Likely cheater
|
||||
_ => RttProbeIntervalNormal // Normal or Possible
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an RTT probe if enough time has passed since the last one.
|
||||
/// Called from movement validation when player is actively moving.
|
||||
/// </summary>
|
||||
public void MaybeSendRttProbe()
|
||||
{
|
||||
// Only probe logged-in players
|
||||
if (Mobile?.Deleted != false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = Core.TickCount;
|
||||
|
||||
// Don't send if we're still waiting for a response
|
||||
if (_rttProbeTime > 0)
|
||||
{
|
||||
// Timeout after 10 seconds - connection is probably dead or very laggy
|
||||
if (now - _rttProbeTime > 10000)
|
||||
{
|
||||
_rttProbeTime = 0;
|
||||
_rttProbeTimestampHiRes = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First probe: send immediately when player starts moving
|
||||
// Subsequent probes: send when interval has passed
|
||||
if (_nextRttProbe == 0 || now >= _nextRttProbe)
|
||||
{
|
||||
_rttProbeTime = now;
|
||||
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
|
||||
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
|
||||
|
||||
if (_movementLogging)
|
||||
{
|
||||
movementLogger.Debug(
|
||||
"[RTT-Probe] {Account}: Sending probe at TickCount={TickCount}",
|
||||
Account?.Username ?? _toString, now
|
||||
);
|
||||
}
|
||||
|
||||
this.SendClientVersionRequest();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an RTT measurement when ClientVersion response is received.
|
||||
/// </summary>
|
||||
public void RecordRttMeasurement()
|
||||
{
|
||||
var nowHiRes = Stopwatch.GetTimestamp();
|
||||
var now = Core.TickCount;
|
||||
|
||||
if (_rttProbeTime <= 0)
|
||||
{
|
||||
// Not expecting a response (client-initiated version send) - ignore silently
|
||||
return;
|
||||
}
|
||||
|
||||
var rtt = now - _rttProbeTime;
|
||||
|
||||
// High-resolution RTT in microseconds
|
||||
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
|
||||
|
||||
if (_movementLogging)
|
||||
{
|
||||
movementLogger.Debug(
|
||||
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
|
||||
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
_rttProbeTime = 0;
|
||||
_rttProbeTimestampHiRes = 0;
|
||||
|
||||
// Sanity check - RTT should be positive and reasonable
|
||||
if (rtt is <= 0 or > 10000)
|
||||
{
|
||||
if (_movementLogging)
|
||||
{
|
||||
movementLogger.Debug(
|
||||
"[RTT-Response] {Account}: Invalid RTT {Rtt}ms, discarding",
|
||||
Account?.Username ?? _toString, rtt
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy init history
|
||||
_rttHistory ??= new long[RttHistorySize];
|
||||
|
||||
// Update history
|
||||
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
|
||||
_lastRtt = rtt;
|
||||
|
||||
// Track sample count (saturates at buffer size)
|
||||
if (_rttSampleCount < RttHistorySize)
|
||||
{
|
||||
_rttSampleCount++;
|
||||
}
|
||||
|
||||
// Recalculate variance
|
||||
UpdateRttVariance();
|
||||
|
||||
if (_movementLogging)
|
||||
{
|
||||
movementLogger.Debug(
|
||||
"[RTT-Response] {Account}: Recorded RTT={Rtt}ms, Avg={Avg}ms, Var={Var}, Samples={Samples}, Stable={Stable}",
|
||||
Account?.Username ?? _toString, rtt, AverageRtt, _rttVariance, _rttSampleCount, HasStableConnection
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the variance of RTT measurements for connection stability assessment.
|
||||
/// </summary>
|
||||
private void UpdateRttVariance()
|
||||
{
|
||||
if (_rttHistory == null)
|
||||
{
|
||||
_rttVariance = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
long sum = 0;
|
||||
long sumSq = 0;
|
||||
int count = 0;
|
||||
|
||||
for (int i = 0; i < RttHistorySize; i++)
|
||||
{
|
||||
var sample = _rttHistory[i];
|
||||
if (sample > 0)
|
||||
{
|
||||
sum += sample;
|
||||
sumSq += sample * sample;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 2)
|
||||
{
|
||||
_rttVariance = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var mean = sum / count;
|
||||
_rttVariance = sumSq / count - mean * mean;
|
||||
|
||||
// Safety clamp: integer division rounding can produce negative variance
|
||||
if (_rttVariance < 0)
|
||||
{
|
||||
_rttVariance = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average RTT from recent measurements.
|
||||
/// </summary>
|
||||
public long AverageRtt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_rttHistory == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long sum = 0;
|
||||
int count = 0;
|
||||
|
||||
for (int i = 0; i < RttHistorySize; i++)
|
||||
{
|
||||
var sample = _rttHistory[i];
|
||||
if (sample > 0)
|
||||
{
|
||||
sum += sample;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count > 0 ? sum / count : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the connection has stable, low-variance, low-latency connection.
|
||||
/// Requires at least 3 samples to make a stability determination.
|
||||
/// Checks both variance (consistency) and absolute latency (quality).
|
||||
/// </summary>
|
||||
public bool HasStableConnection =>
|
||||
_rttSampleCount >= 3 &&
|
||||
_rttVariance < StableVarianceThreshold &&
|
||||
AverageRtt > 0 &&
|
||||
AverageRtt < MaxStableLatency;
|
||||
}
|
||||
|
|
@ -353,6 +353,9 @@ public partial class NetState
|
|||
_throttled.Enqueue(_throttledPending.Dequeue());
|
||||
}
|
||||
|
||||
// Process queued movements at proper intervals
|
||||
MovementThrottle.ProcessAllQueues();
|
||||
|
||||
// Process all completions through the manager FIRST
|
||||
// This ensures DataReceived events are processed and HandleReceive runs,
|
||||
// which may call Send() and add to _flushPending
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
// Managed socket with buffers (handles lifecycle automatically)
|
||||
internal RingSocket _socket;
|
||||
|
||||
// Speed Hack Prevention
|
||||
internal long _movementCredit;
|
||||
internal long _nextMovementTime;
|
||||
// General packet throttle state (used for other throttled packets)
|
||||
internal bool _isThrottled;
|
||||
|
||||
private IAccount _account;
|
||||
|
||||
internal enum ParserState
|
||||
|
|
@ -942,6 +942,14 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
|
|||
}
|
||||
else
|
||||
{
|
||||
// Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
|
||||
// The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
|
||||
if (_account != null && Mobile == null)
|
||||
{
|
||||
this.SendClientVersionRequest();
|
||||
return;
|
||||
}
|
||||
|
||||
LogInfo("Disconnecting due to inactivity...");
|
||||
Disconnect("Disconnecting due to inactivity.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ public static class OutgoingAccountPackets
|
|||
* Sends a requests for the client version
|
||||
*/
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void SendClientVersionRequest(this NetState ns) => ns?.Send(stackalloc byte[] { 0xBD, 0x00, 0x03 });
|
||||
public static void SendClientVersionRequest(this NetState ns) => ns?.Send([0xBD, 0x00, 0x03]);
|
||||
|
||||
/**
|
||||
* Packet: 0x85
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue