modernuo/Projects/Server/Network/NetState/NetState.Movement.cs
Kamron Batman 04d438239d
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
   }
   ```
2026-03-07 11:44:37 -08:00

389 lines
14 KiB
C#

/*************************************************************************
* 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;
}