/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * * File: MovementThrottle.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 . * *************************************************************************/ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using Server.Logging; namespace Server.Network; /// /// Movement throttle system using a hybrid credit + queue approach. /// Credit buffer absorbs small timing jitter from legitimate players. /// Queue handles larger bursts, draining at proper intervals. /// Speed hacks detected by movement rate analysis and sustained queue depth. /// public static class MovementThrottle { private static readonly ILogger logger = LogFactory.GetLogger(typeof(MovementThrottle)); // Configuration values private static int _maxCredit = 200; // Max credit buffer (ms) private static int _maxRttBonus = 150; // Max extra credit for high-latency players (ms) private static int _hardQueueLimit = 10; // Reject and clear at this limit // Movement history configuration private static int _movementHistorySize = 20; // Circular buffer size private static int _minSamplesForRate = 8; // Minimum movements to calculate rate private static int _maxChainGap = 2000; // Gap (ms) that breaks a movement chain private static float _suspiciousRateThreshold = 1.05f; // 5% faster than expected private static float _definiteRateThreshold = 1.10f; // 10% faster than expected private static int _speedHackNotificationCooldown = 300000; // 5 minutes between notifications per player // Client movement limits (UO protocol constants) // The client can have at most 5 unacknowledged movements pending. // This means our server-side queue should never exceed 4 with an unmodified client. // Queue > 4 indicates client modification (not just speed hack checkbox in Cheat Engine). private const int ClientMaxUnackedMovements = 5; private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 // Debug logging - enable for testing speed hack detection private static bool _debugLogging = false; // Track NetStates with queued movements for efficient processing private static readonly HashSet _netStatesWithQueuedMovements = new(256); /// /// Gets the dynamic credit buffer for a connection based on measured RTT. /// High-latency players get more tolerance; stable low-latency gets tighter security. /// private static int GetDynamicCredit(NetState ns) { var avgRtt = ns.AverageRtt; // No RTT data yet - use default if (avgRtt <= 0) { return _maxCredit; } // Stable, low-latency connection - use base credit (tighter security) if (ns.HasStableConnection && avgRtt < 50) { return _maxCredit; } // Add RTT-based bonus for higher latency, capped at max bonus var rttBonus = Math.Min(avgRtt / 2, _maxRttBonus); return _maxCredit + (int)rttBonus; } public static void Configure() { _maxCredit = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.maxCredit", _maxCredit ); _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.hardQueueLimit", _hardQueueLimit ); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", _movementHistorySize ); _minSamplesForRate = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.minSamplesForRate", _minSamplesForRate ); _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold ); _definiteRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); _debugLogging = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.debugLogging", _debugLogging ); } /// /// Called from MovementReq packet handler. Validates timing and either /// executes the movement immediately or queues it for later execution. /// public static void ValidateAndQueueMovement(NetState ns, Mobile mobile, Direction dir, int seq) { if (mobile?.Deleted != false) { return; } // Staff bypass all throttling; also bypass when speedhack detection is disabled if (mobile.AccessLevel > AccessLevel.Player || !ServerFeatureFlags.SpeedhackDetection) { ExecuteMovement(ns, mobile, dir, seq); return; } // Check for sequence mismatch (sequence was reset by paralysis, teleport, etc.) if (ns.Sequence == 0 && seq != 0) { RejectAndReset(ns, mobile, seq); return; } var now = Core.TickCount; // Track movement packet rate (packets per second) ns.TrackMovementRate(); // RTT probe - only when actively moving (event-driven, not global loop) ns.MaybeSendRttProbe(); // Calculate movement cost - this has FULL CONTEXT (mounted, running, direction change) var cost = mobile.ComputeMovementSpeed(dir); // Record movement in history for rate analysis (do this early, before any returns) RecordMovement(ns, now, cost, dir, mobile); // Periodic rate-based detection (every 2 seconds, regardless of queue depth) if (now - ns._lastQueueDepthCheck >= 2000) { ns._lastQueueDepthCheck = now; var verdict = CheckAndNotifyStaff(ns); // Adjust probe frequency based on detection verdict ns.SetProbeFrequency((int)verdict); } // Calculate timing delta: positive = on-time/late, negative = early var delta = now - ns._nextMovementTime; // If there are already queued movements, add to queue to maintain order if (ns._hasQueuedMovements) { QueueMovement(ns, dir, seq); return; } // Get dynamic credit limit based on connection latency var dynamicCredit = GetDynamicCredit(ns); // Handle early packets with credit buffer if (delta < 0) { // Packet arrived early var earlyAmount = -delta; // Can we absorb this with credit? // Credit can go negative up to -dynamicCredit (debt limit) if (ns._movementCredit - earlyAmount >= -dynamicCredit) { var prevCredit = ns._movementCredit; // Use credit to cover early arrival ns._movementCredit -= earlyAmount; if (_debugLogging && ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit ); } // Execute immediately since credit covers it ExecuteMovement(ns, mobile, dir, seq); return; } if (_debugLogging && ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit ); } // Credit exhausted - must queue QueueMovement(ns, dir, seq); return; } // On-time or late - rebuild credit (capped at dynamic max) if (delta > 0) { var prevCredit = ns._movementCredit; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit ); } } // Execute immediately ExecuteMovement(ns, mobile, dir, seq); } /// /// Executes a single movement and updates state. /// private static void ExecuteMovement(NetState ns, Mobile mobile, Direction dir, int seq) { if (!mobile.Move(dir)) { if (_debugLogging && ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile.RawName, dir, seq ); } // Movement failed (blocked, paralyzed, frozen, etc.) RejectAndReset(ns, mobile, seq); return; } if (_debugLogging && ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount ); } // Success - Mobile.Move() already updated _nextMovementTime and sent ack // Update sequence var newSeq = seq + 1; if (newSeq == 256) { newSeq = 1; } ns.Sequence = newSeq; } /// /// Queues a movement for later execution. /// private static void QueueMovement(NetState ns, Direction dir, int seq) { // Lazy initialize queue ns._movementQueue ??= new Queue(_hardQueueLimit); // Check hard limit if (ns._movementQueue.Count >= _hardQueueLimit) { LogQueueOverflow(ns); RejectAndReset(ns, ns.Mobile, seq); return; } // Enqueue ns._movementQueue.Enqueue(new NetState.QueuedMovement { Direction = dir, Sequence = seq }); ns._hasQueuedMovements = true; _netStatesWithQueuedMovements.Add(ns); if (_debugLogging && ns._movementLogging) { logger.Debug( "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count ); } } /// /// Rejects a movement and resets movement state. /// private static void RejectAndReset(NetState ns, Mobile mobile, int seq) { ns.SendMovementRej(seq, mobile); ns.ResetMovementState(); _netStatesWithQueuedMovements.Remove(ns); } /// /// Processes queued movements for all NetStates. Called from NetState.Slice(). /// public static void ProcessAllQueues() { if (_netStatesWithQueuedMovements.Count == 0) { return; } // Process each NetState with queued movements // Use a snapshot to avoid modification during iteration var toProcess = new List(_netStatesWithQueuedMovements); for (var i = 0; i < toProcess.Count; i++) { var ns = toProcess[i]; if (!ns.Running) { _netStatesWithQueuedMovements.Remove(ns); continue; } ProcessMovementQueue(ns); } } /// /// Processes the movement queue for a single NetState. /// public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; if (mobile?.Deleted != false) { ClearQueue(ns); return; } // Staff don't queue if (mobile.AccessLevel > AccessLevel.Player) { DrainQueueImmediately(ns, mobile); return; } var now = Core.TickCount; while (ns._movementQueue?.Count > 0) { // Check if it's time to execute if (now < ns._nextMovementTime) { // Not yet - leave remaining items in queue for next Slice break; } var movement = ns._movementQueue.Dequeue(); var remaining = ns._movementQueue.Count; // Validate sequence if (ns.Sequence == 0 && movement.Sequence != 0) { // Sequence was reset (paralysis, teleport, etc.) RejectAndReset(ns, mobile, movement.Sequence); return; } // Execute the move if (!mobile.Move(movement.Direction)) { if (_debugLogging && ns._movementLogging) { logger.Debug( "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", mobile.RawName, movement.Direction, remaining ); } // Movement failed RejectAndReset(ns, mobile, movement.Sequence); return; } if (_debugLogging && ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 ); } // Success - update sequence var newSeq = movement.Sequence + 1; if (newSeq == 256) { newSeq = 1; } ns.Sequence = newSeq; // Refresh time for next iteration now = Core.TickCount; } // Update tracking ns._hasQueuedMovements = ns._movementQueue?.Count > 0; if (!ns._hasQueuedMovements) { _netStatesWithQueuedMovements.Remove(ns); } } /// /// Drains the entire queue immediately for staff members. /// private static void DrainQueueImmediately(NetState ns, Mobile mobile) { while (ns._movementQueue?.Count > 0) { var movement = ns._movementQueue.Dequeue(); if (!mobile.Move(movement.Direction)) { RejectAndReset(ns, mobile, movement.Sequence); return; } var newSeq = movement.Sequence + 1; if (newSeq == 256) { newSeq = 1; } ns.Sequence = newSeq; } ClearQueue(ns); } /// /// Clears the movement queue for a NetState. /// private static void ClearQueue(NetState ns) { ns._movementQueue?.Clear(); ns._hasQueuedMovements = false; _netStatesWithQueuedMovements.Remove(ns); } // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) private const int MaxExpectedPacketRate = 12; /// /// Logs when a queue overflow occurs (hard limit reached). /// private static void LogQueueOverflow(NetState ns) { var mobile = ns.Mobile; logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address ); } /// /// Resets movement timing for all connected players after a world save. /// This prevents post-save burst rejections. /// public static void ResetAllMovementTiming() { foreach (var ns in NetState.Instances) { if (ns.Mobile?.Deleted == false) { ns._nextMovementTime = Core.TickCount; ns._movementCredit = _maxCredit; // Give full credit after save } } } /// /// Records a movement in the history buffer for rate analysis. /// This is a hot path - optimized for minimal allocations and branches. /// Skips recording for chain-breaking movements (first in sequence, long gaps). /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) { // Calculate interval since last movement var interval = ns._lastMovementRecordTime > 0 ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) // Skip recording for chain-breaking movements - they waste buffer space // and get skipped during analysis anyway. Still update timestamp for next movement. if (interval <= 0 || interval > _maxChainGap) { ns._lastMovementRecordTime = now; // Use RTT to distinguish "stopped moving" vs "lagged" // - Stable low-latency connection with gap >> RTT → player stopped, reset history // - Unstable/high-latency connection with gap → might be lag, preserve history if (interval > _maxChainGap) { var avgRtt = ns.AverageRtt; var shouldReset = ns.HasStableConnection && avgRtt > 0 && avgRtt < 200 && avgRtt < interval / 4; if (shouldReset) { ns._movementHistoryIndex = 0; ns._movementHistoryFull = false; } // Track gap duration for burst forgiveness logic // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; if (_debugLogging && mobile?.RawName != null) { var action = shouldReset ? "history reset" : "history preserved (possible lag)"; logger.Debug( "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "RTT={RTT}ms stable={Stable} → {Action})", mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action ); } } else if (_debugLogging && mobile?.RawName != null) { logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); } return; } // Lazy initialize buffer ns._movementHistory ??= new NetState.MovementRecord[_movementHistorySize]; // Build flags // Direction-only changes (cost=0) don't contribute to rate calculation. // Don't record them - they would pollute interval measurements. // Example bug if recorded: direction changes between real moves make // the next real move's interval artificially short, inflating rate. if (cost == 0) { if (_debugLogging && mobile?.RawName != null) { logger.Debug( "[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile.RawName ); } return; } var flags = NetState.MovementRecordFlags.None; if ((dir & Direction.Running) != 0) { flags |= NetState.MovementRecordFlags.Running; } if (mobile.Mounted) { flags |= NetState.MovementRecordFlags.Mounted; } if (ns._hasQueuedMovements) { flags |= NetState.MovementRecordFlags.WasQueued; } // Write to circular buffer (struct assignment, no heap allocation) // interval is already validated: > 0 and <= _maxChainGap (2000ms < short.MaxValue) ref var record = ref ns._movementHistory[ns._movementHistoryIndex]; record.Interval = (short)interval; record.TargetSpeed = (ushort)cost; record.QueueDepth = (byte)Math.Min(ns._movementQueue?.Count ?? 0, 255); record.Flags = (byte)flags; // Advance index ns._movementHistoryIndex++; if (ns._movementHistoryIndex >= _movementHistorySize) { ns._movementHistoryIndex = 0; ns._movementHistoryFull = true; } ns._lastMovementRecordTime = now; // Debug logging if (_debugLogging && mobile?.RawName != null) { var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; logger.Debug( "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", mobile.RawName, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } } /// /// Calculates the movement rate ratio: targetTime / actualTime. /// Returns 1.0 for normal speed, >1.0 for faster than allowed. /// /// The NetState to analyze /// Output: number of samples used in calculation /// Rate ratio (1.0 = normal, 1.1 = 10% faster) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static float CalculateMovementRate(NetState ns, out int sampleCount) { sampleCount = 0; if (ns._movementHistory == null) { return 1.0f; } var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; if (historyCount < _minSamplesForRate) { return 1.0f; } long totalTarget = 0; long totalActual = 0; var count = 0; // Walk backwards through history from most recent // All entries are guaranteed valid (interval > 0 and <= _maxChainGap) due to // filtering in RecordMovement() and history reset on chain breaks. for (var i = 0; i < historyCount; i++) { // Calculate index going backwards from current position var idx = ns._movementHistoryIndex - 1 - i; if (idx < 0) { idx += _movementHistorySize; } ref readonly var record = ref ns._movementHistory[idx]; // Skip direction-only changes (cost = 0) - they don't contribute to movement rate if ((record.Flags & (byte)NetState.MovementRecordFlags.DirectionChangeOnly) != 0) { continue; } totalTarget += record.TargetSpeed; totalActual += record.Interval; count++; } sampleCount = count; if (count < _minSamplesForRate || totalActual <= 0) { return 1.0f; } return (float)totalTarget / totalActual; } /// /// Dumps movement history for debugging rate calculation. /// private static void DumpMovementHistory(NetState ns, int limit) { if (ns._movementHistory == null) { logger.Debug(" [History] null"); return; } var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; var showCount = Math.Min(limit, historyCount); long totalTarget = 0; long totalActual = 0; logger.Debug(" [History] Showing {ShowCount} of {HistoryCount} entries:", showCount, historyCount); for (var i = 0; i < showCount; i++) { var idx = ns._movementHistoryIndex - 1 - i; if (idx < 0) { idx += _movementHistorySize; } ref readonly var record = ref ns._movementHistory[idx]; var flags = (NetState.MovementRecordFlags)record.Flags; totalTarget += record.TargetSpeed; totalActual += record.Interval; var cumRate = totalActual > 0 ? (float)totalTarget / totalActual : 0; logger.Debug( " [{Index}] interval={Interval}ms target={Target}ms queue={Queue} flags={Flags} cumRate={CumRate:F3}", i, record.Interval, record.TargetSpeed, record.QueueDepth, flags, cumRate ); } } /// /// Detects if recent movements arrived as a burst (multiple packets with near-zero intervals). /// /// The NetState to analyze /// Tuple of (burst size, preceding gap in ms) public static (int burstSize, int precedingGap) DetectRecentBurst(NetState ns) { if (ns._movementHistory == null) { return (0, 0); } var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; if (historyCount < 2) { return (0, 0); } const int burstThreshold = 15; // Packets within 15ms are "simultaneous" var burstSize = 0; var precedingGap = 0; for (var i = 0; i < historyCount; i++) { var idx = ns._movementHistoryIndex - 1 - i; if (idx < 0) { idx += _movementHistorySize; } var interval = ns._movementHistory[idx].Interval; // All intervals are guaranteed > 0 due to filtering in RecordMovement() if (interval <= burstThreshold) { burstSize++; } else { precedingGap = interval; break; } } return (burstSize, precedingGap); } /// /// Detection verdict levels for speed hack analysis. /// public enum DetectionVerdict { Normal, // Rate within tolerance Possible, // Slight anomaly, could be network Likely, // Multiple signals point to cheating Definite // Clear speed hacking } /// /// Analyzes movement patterns and calculates detection confidence. /// /// The NetState to analyze /// Output: calculated movement rate /// Output: number of samples used /// Output: confidence score 0.0-1.0 /// Detection verdict public static DetectionVerdict AnalyzeMovement( NetState ns, out float rate, out int sampleCount, out float confidence ) { rate = CalculateMovementRate(ns, out sampleCount); confidence = 0f; // Not enough data if (sampleCount < _minSamplesForRate) { return DetectionVerdict.Normal; } var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging if (_debugLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); DumpMovementHistory(ns, sampleCount); } // Signal 1: Movement rate (primary signal) if (rate > 1.15f) { confidence += 0.5f; } else if (rate > _definiteRateThreshold) { confidence += 0.35f; } else if (rate > _suspiciousRateThreshold) { confidence += 0.2f; } else if (rate > 1.02f) { confidence += 0.1f; } // Signal 2: Packet rate (secondary signal) var packetRate = ns.CurrentMovementRate; if (packetRate > 15) { confidence += 0.15f; } else if (packetRate > MaxExpectedPacketRate) { confidence += 0.1f; } // Signal 2b: Queue depth (critical for ACK-throttled speed hacks) // The UO client limits unacked movements to 5, so our queue should max at 4. // When going straight at high speed, ACK flow limits packet rate, but queue stays high. // Queue > 4 indicates a modified client (not just Cheat Engine speed hack). var queueDepth = ns._movementQueue?.Count ?? 0; if (queueDepth > MaxQueueWithUnmodifiedClient) { confidence += 0.5f; // Modified client - extremely suspicious } else if (queueDepth >= MaxQueueWithUnmodifiedClient) { confidence += 0.25f; // At client limit - speed hack with unmodified client } else if (queueDepth >= 2) { confidence += 0.1f; } // Signal 3: RTT correlation (modifier) if (ns.HasStableConnection && averageRtt < 100) { // Stable, low-latency connection - problems are more suspicious if (rate > 1.02f) { confidence += 0.2f; } } else if (ns._rttVariance > 10000 || averageRtt > 300) { // Unstable connection - reduce confidence confidence *= 0.6f; } // Signal 4: Burst pattern analysis var (burstSize, _) = DetectRecentBurst(ns); // Forgiveness for lag recovery: burst preceded by large gap // A legitimate lag spike will have a large gap followed by burst of packets arriving at once // These packets will have HIGH rate (tiny intervals) but it's not cheating if (burstSize >= 3 && ns._lastGapDuration > _maxChainGap / 2) { // Recent large gap + burst = lag recovery, strong forgiveness confidence *= 0.3f; ns._lastGapDuration = 0; // Reset after applying forgiveness } else if (burstSize >= 3 && rate < _suspiciousRateThreshold) { // Burst pattern with normal rate = likely legitimate lag confidence *= 0.5f; } // Signal 5: Sample count modifier if (sampleCount < 10) { confidence *= 0.7f; } else if (sampleCount >= 20) { confidence = Math.Min(confidence * 1.1f, 1.0f); } // Determine verdict if (rate > _definiteRateThreshold && confidence > 0.6f) { return DetectionVerdict.Definite; } if (rate > 1.20f) { return DetectionVerdict.Definite; } // Modified client: queue exceeds what unmodified client can produce // This is impossible with unmodified client, so it's definite regardless of persistence if (queueDepth > MaxQueueWithUnmodifiedClient) { return DetectionVerdict.Definite; } // Queue-based detection: requires SUSTAINED high queue, not momentary spike // A legitimate lag burst can momentarily fill queue to 4, but it drains quickly. // Speed hackers maintain queue at 4 continuously because they send at max ACK rate. // We use Likely here and require sustained detection before alerting. if (queueDepth >= MaxQueueWithUnmodifiedClient && ns.HasStableConnection && averageRtt < 100) { // Stable low-latency connection with high queue - suspicious but needs sustained check return DetectionVerdict.Likely; } if (queueDepth >= 3 && ns.HasStableConnection) { return DetectionVerdict.Likely; } if (rate > _suspiciousRateThreshold && confidence > 0.4f && ns.HasStableConnection) { return DetectionVerdict.Likely; } if (rate > 1.03f && confidence > 0.2f) { return DetectionVerdict.Possible; } if (packetRate > 14) { return DetectionVerdict.Possible; } if (queueDepth >= 2 && ns.HasStableConnection) { return DetectionVerdict.Possible; } return DetectionVerdict.Normal; } /// /// Checks if staff should be notified about suspicious movement. /// Called periodically during sustained queue depth tracking. /// Returns the detection verdict for probe frequency adjustment. /// public static DetectionVerdict CheckAndNotifyStaff(NetState ns) { var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging if (_debugLogging && ns.Mobile?.RawName != null) { var (burstSize, _) = DetectRecentBurst(ns); var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; var queueDepth = ns._movementQueue?.Count ?? 0; logger.Debug( "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ); logger.Debug( " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ); } // Update sustained counter based on verdict if (verdict >= DetectionVerdict.Likely) { ns._consecutiveHighRateSeconds += 2; // Called every ~2 seconds } else { ns._consecutiveHighRateSeconds = Math.Max(0, ns._consecutiveHighRateSeconds - 1); } // Determine if we should notify var shouldNotify = false; var urgency = ""; if (verdict == DetectionVerdict.Definite) { shouldNotify = true; urgency = "HIGH"; } else if (verdict == DetectionVerdict.Likely && ns._consecutiveHighRateSeconds >= 10) { shouldNotify = true; urgency = "MEDIUM"; } else if (verdict == DetectionVerdict.Possible && ns._consecutiveHighRateSeconds >= 30) { shouldNotify = true; urgency = "LOW"; } if (shouldNotify) { if (_debugLogging) { logger.Debug( "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", urgency, ns.Mobile?.RawName, rate, verdict, confidence ); } NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); } return verdict; } /// /// Sends notification to staff about potential speed hacking. /// Rate-limited per player. /// private static void NotifyStaff( NetState ns, float rate, int sampleCount, float confidence, DetectionVerdict verdict, string urgency ) { var now = Core.TickCount; // Rate-limit notifications per player if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; // Log to file with full details logger.Warning( "[{Urgency}] Speed hack detected: {Character} ({Account}) | " + "Rate: {Rate:F2} ({Samples} samples) | Verdict: {Verdict} | Confidence: {Confidence:P0} | " + "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", urgency, mobile?.RawName ?? "Unknown", ns.Account?.Username ?? "Unknown", rate, sampleCount, verdict, confidence, ns.CurrentMovementRate, ns.PeakMovementRate, ns.AverageRtt, ns.HasStableConnection, ns._consecutiveHighRateSeconds, ns._movementQueue?.Count ?? 0, mobile?.Location, mobile?.Map?.Name, ns.Address ); // Invoke callback for staff notification (if configured) // Server operators can hook this to broadcast to staff OnSpeedHackDetected?.Invoke(ns, mobile, rate, verdict, urgency); } /// /// Event raised when a speed hack is detected. /// Server operators can subscribe to broadcast to staff or take other actions. /// public static event Action OnSpeedHackDetected; /// /// Snapshot of a player's movement throttle state for diagnostic display. /// public readonly struct MovementStats { public float Rate { get; init; } public int SampleCount { get; init; } public DetectionVerdict Verdict { get; init; } public float Confidence { get; init; } public long AverageRtt { get; init; } public long LastRtt { get; init; } public long RttVariance { get; init; } public bool StableConnection { get; init; } public int RttSampleCount { get; init; } public int QueueDepth { get; init; } public long MovementCredit { get; init; } public int CurrentPacketRate { get; init; } public int PeakPacketRate { get; init; } public int BurstSize { get; init; } public int PrecedingGap { get; init; } public int SustainedSeconds { get; init; } public bool DebugLogging { get; init; } } /// /// Gets a snapshot of the current movement throttle state for a player. /// Safe to call from UOContent (exposes internal state via public struct). /// public static MovementStats GetMovementStats(NetState ns) { var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); var (burstSize, precedingGap) = DetectRecentBurst(ns); return new MovementStats { Rate = rate, SampleCount = sampleCount, Verdict = verdict, Confidence = confidence, AverageRtt = ns.AverageRtt, LastRtt = ns._lastRtt, RttVariance = ns._rttVariance, StableConnection = ns.HasStableConnection, RttSampleCount = ns._rttSampleCount, QueueDepth = ns._movementQueue?.Count ?? 0, MovementCredit = ns._movementCredit, CurrentPacketRate = ns.CurrentMovementRate, PeakPacketRate = ns.PeakMovementRate, BurstSize = burstSize, PrecedingGap = precedingGap, SustainedSeconds = ns._consecutiveHighRateSeconds, DebugLogging = ns.MovementLogging, }; } }