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:
Kamron Batman 2026-03-07 11:44:37 -08:00 • committed by GitHub
parent 6745cf2075
commit 04d438239d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2474 additions and 74 deletions

View file

@ -0,0 +1,94 @@
using Server.Commands;
using Server.Network;
using Server.Targeting;
namespace Server.Commands;
public static class MovementDebugCommands
{
public static void Configure()
{
CommandSystem.Register("MovementDebug", AccessLevel.GameMaster, MovementDebug_OnCommand);
CommandSystem.Register("MovementStats", AccessLevel.GameMaster, MovementStats_OnCommand);
// Subscribe to speed hack detection events and broadcast to online staff
MovementThrottle.OnSpeedHackDetected += OnSpeedHackDetected;
}
private static void OnSpeedHackDetected(
NetState ns,
Mobile mobile,
float rate,
MovementThrottle.DetectionVerdict verdict,
string urgency)
{
if (mobile == null)
{
return;
}
var hue = urgency switch
{
"HIGH" => 0x25, // Red
"MEDIUM" => 0x35, // Orange
_ => 0x3B // Yellow
};
CommandHandlers.BroadcastMessage(
AccessLevel.Counselor,
hue,
$"[{urgency}] Speed hack: {mobile.RawName} ({ns.Account?.Username}) " +
$"Rate:{rate:F2} Verdict:{verdict} @ {mobile.Location} {mobile.Map?.Name}"
);
}
[Usage("MovementDebug")]
[Description("Target a player to toggle verbose movement logging for their connection.")]
private static void MovementDebug_OnCommand(CommandEventArgs e)
{
e.Mobile.BeginTarget(-1, false, TargetFlags.None, MovementDebug_OnTarget);
e.Mobile.SendMessage("Target a player to toggle movement debug logging.");
}
private static void MovementDebug_OnTarget(Mobile from, object obj)
{
if (obj is not Mobile target || target.NetState == null)
{
from.SendMessage("That is not a valid online player.");
return;
}
var ns = target.NetState;
ns.MovementLogging = !ns.MovementLogging;
var state = ns.MovementLogging ? "ENABLED" : "DISABLED";
from.SendMessage($"Movement debug logging {state} for {target.RawName}.");
}
[Usage("MovementStats")]
[Description("Target a player to see their current movement throttle state.")]
private static void MovementStats_OnCommand(CommandEventArgs e)
{
e.Mobile.BeginTarget(-1, false, TargetFlags.None, MovementStats_OnTarget);
e.Mobile.SendMessage("Target a player to view their movement stats.");
}
private static void MovementStats_OnTarget(Mobile from, object obj)
{
if (obj is not Mobile target || target.NetState == null)
{
from.SendMessage("That is not a valid online player.");
return;
}
var stats = MovementThrottle.GetMovementStats(target.NetState);
from.SendMessage($"--- Movement Stats for {target.RawName} ---");
from.SendMessage($"Rate: {stats.Rate:F3} ({stats.SampleCount} samples) | Verdict: {stats.Verdict} | Confidence: {stats.Confidence:P0}");
from.SendMessage($"RTT: avg={stats.AverageRtt}ms last={stats.LastRtt}ms var={stats.RttVariance} stable={stats.StableConnection} samples={stats.RttSampleCount}");
from.SendMessage($"Queue: depth={stats.QueueDepth} | Credit: {stats.MovementCredit}ms");
from.SendMessage($"Packet rate: {stats.CurrentPacketRate}/s (peak: {stats.PeakPacketRate}/s)");
from.SendMessage($"Burst: size={stats.BurstSize} gap={stats.PrecedingGap}ms | Sustained: {stats.SustainedSeconds}s");
from.SendMessage($"Debug logging: {(stats.DebugLogging ? "ON" : "OFF")}");
}
}

View file

@ -964,7 +964,8 @@ public static class FeatureFlagManager
// Server project flags
"player_trading" => ServerFeatureFlags.PlayerTrading = enabled,
"pvp_combat" => ServerFeatureFlags.PvPCombat = enabled,
"bank_access" => ServerFeatureFlags.BankAccess = enabled,
"bank_access" => ServerFeatureFlags.BankAccess = enabled,
"speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled,
// UOContent flags
"vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled,

View file

@ -194,6 +194,9 @@ public static class IncomingAccountPackets
{
var version = state.Version = new ClientVersion(reader.ReadAscii());
// Record RTT if this is a response to our probe
state.RecordRttMeasurement();
ClientVerification.ClientVersionReceived(state, version);
}
@ -204,6 +207,9 @@ public static class IncomingAccountPackets
int type = reader.ReadUInt16();
var version = state.Version = new ClientVersion(reader.ReadAscii());
// Record RTT if this is a response to our probe
state.RecordRttMeasurement();
ClientVerification.ClientVersionReceived(state, version);
}
@ -270,7 +276,7 @@ public static class IncomingAccountPackets
state.SendSupportedFeature();
state.Sequence = 0;
state.ResetMovementState();
state.SendMobileUpdate(m);
state.SendMobileUpdate(m);

View file

@ -91,23 +91,9 @@ public static class IncomingMovementPackets
var dir = (Direction)reader.ReadByte();
int seq = reader.ReadByte();
var key = reader.ReadUInt32();
var key = reader.ReadUInt32(); // FastWalkStack key - not used (not on EA servers)
if (state.Sequence == 0 && seq != 0 || !from.Move(dir))
{
state.SendMovementRej(seq, from);
state.Sequence = 0;
}
else
{
++seq;
if (seq == 256)
{
seq = 1;
}
state.Sequence = seq;
}
// Delegate to MovementThrottle which has full context for timing validation
MovementThrottle.ValidateAndQueueMovement(state, from, dir, seq);
}
}

View file

@ -360,7 +360,7 @@ public static class IncomingPlayerPackets
from.SendEverything();
state.Sequence = 0;
state.ResetMovementState();
}
public static void PingReq(NetState state, SpanReader reader)