diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs new file mode 100644 index 000000000..63cb9d4a6 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleLatencyTests.cs @@ -0,0 +1,430 @@ +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Manual tests for MovementThrottle latency scenarios. +/// These tests simulate specific timing scenarios and should NOT run in CI/CD. +/// +/// To run these tests manually: +/// dotnet test --filter "Category=Manual" +/// +/// To exclude from CI/CD, add to your test command: +/// dotnet test --filter "Category!=Manual" +/// +[Collection("Sequential Server Tests")] +[Trait("Category", "Manual")] +public class MovementThrottleLatencyTests +{ + /// + /// Sets the simulated tick count for testing. + /// + private static void SetTickCount(long ticks) + { + Core._tickCount = ticks; + } + + /// + /// Simulates recording movements at specific intervals. + /// + private static void SimulateMovements(NetState ns, params (int delayMs, int costMs)[] movements) + { + ns._movementHistory ??= new NetState.MovementRecord[20]; + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = false; + ns._lastMovementRecordTime = 0; + + var currentTime = 0L; + + foreach (var (delayMs, costMs) in movements) + { + currentTime += delayMs; + SetTickCount(currentTime); + + if (ns._lastMovementRecordTime > 0) + { + var interval = (int)(currentTime - ns._lastMovementRecordTime); + + // Skip chain-breaking gaps (> 2000ms) like the real code does + if (interval > 2000) + { + ns._lastGapDuration = interval; + ns._lastMovementRecordTime = currentTime; + continue; + } + + ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord + { + Interval = (short)interval, + TargetSpeed = (ushort)costMs, + QueueDepth = 0, + Flags = 0 + }; + + ns._movementHistoryIndex++; + if (ns._movementHistoryIndex >= 20) + { + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = true; + } + } + + ns._lastMovementRecordTime = currentTime; + } + } + + /// + /// Scenario: Player experiences a 3-second network lag spike, then all buffered + /// packets arrive at once. This should NOT trigger false positive detection. + /// + /// Timeline: + /// - T=0-1000ms: Normal movement (10 packets at 100ms intervals) + /// - T=1000-4000ms: Network lag (client buffers packets) + /// - T=4000ms: Network recovers, 30 buffered packets arrive simultaneously + /// + [Fact] + public void LagSpike_BurstRecovery_NoFalsePositive() + { + var ns = PacketTestUtilities.CreateTestNetState(); + SetTickCount(0); + + // Phase 1: Normal movement for 1 second + var movements = new (int delayMs, int costMs)[10]; + for (var i = 0; i < 10; i++) + { + movements[i] = (100, 100); // Normal mounted running + } + SimulateMovements(ns, movements); + + // Verify normal rate before lag + var rateBeforeLag = MovementThrottle.CalculateMovementRate(ns, out var samples); + Assert.True(samples >= 8, $"Expected >= 8 samples, got {samples}"); + Assert.True(rateBeforeLag >= 0.95f && rateBeforeLag <= 1.05f, + $"Expected normal rate before lag, got {rateBeforeLag}"); + + // Phase 2: Simulate 3 second lag followed by burst + // The gap (3000ms) is tracked, then burst packets arrive + var currentTime = Core.TickCount; + + // Record the lag gap + SetTickCount(currentTime + 3000); + ns._lastGapDuration = 3000; // Gap > maxChainGap triggers this + + // Burst packets arriving (5 packets with tiny intervals) + for (var i = 0; i < 5; i++) + { + SetTickCount(Core.TickCount + 2); // 2ms between packets (simultaneous arrival) + + if (ns._lastMovementRecordTime > 0) + { + var interval = (int)(Core.TickCount - ns._lastMovementRecordTime); + ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord + { + Interval = (short)interval, + TargetSpeed = 100, + QueueDepth = 0, + Flags = 0 + }; + ns._movementHistoryIndex++; + if (ns._movementHistoryIndex >= 20) + { + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = true; + } + } + ns._lastMovementRecordTime = Core.TickCount; + } + + // Analyze - should apply burst forgiveness due to large gap + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); + + // The burst will have high rate due to tiny intervals, but: + // 1. _lastGapDuration > 1000 (maxChainGap/2) triggers burst forgiveness + // 2. Confidence should be reduced by 0.3f multiplier + // 3. Should NOT be Definite unless truly extreme + Assert.True( + verdict != MovementThrottle.DetectionVerdict.Definite, + $"Lag recovery should not be Definite. Rate={rate}, Confidence={confidence}, Verdict={verdict}" + ); + + // Gap duration should be cleared after analysis + Assert.Equal(0, ns._lastGapDuration); + } + + /// + /// Scenario: Player with consistently high latency (400ms RTT) moves normally. + /// Their connection is consistent but slow - should NOT be considered "stable" + /// for the purpose of tightening detection thresholds. + /// + [Fact] + public void HighLatencyConsistent_NotConsideredStable() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // Set up high but consistent RTT + ns._rttHistory = new long[] { 400, 405, 398, 402, 401, 399, 403, 400 }; + ns._rttSampleCount = 8; + + // Calculate variance (should be low since values are consistent) + long sum = 0, sumSq = 0; + for (var i = 0; i < 8; i++) + { + sum += ns._rttHistory[i]; + sumSq += ns._rttHistory[i] * ns._rttHistory[i]; + } + var mean = sum / 8; + ns._rttVariance = sumSq / 8 - mean * mean; + + // Variance is low (consistent), but should NOT be "stable" due to high latency + Assert.False(ns.HasStableConnection, + $"400ms RTT should not be stable. AvgRTT={ns.AverageRtt}, Variance={ns._rttVariance}"); + + // Now verify detection gives more leniency for high-latency connections + SimulateMovements(ns, + (105, 100), (105, 100), (105, 100), (105, 100), (105, 100), + (105, 100), (105, 100), (105, 100), (105, 100), (105, 100) + ); + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); + + // Slightly slow but high-latency player should get benefit of doubt + Assert.True( + verdict == MovementThrottle.DetectionVerdict.Normal || + verdict == MovementThrottle.DetectionVerdict.Possible, + $"High latency player should get leniency. Verdict={verdict}" + ); + } + + /// + /// Scenario: RTT probing and measurement. + /// Verifies that RTT variance is calculated correctly and stability + /// is properly determined. + /// + [Fact] + public void RttMeasurement_VarianceCalculation() + { + var ns = PacketTestUtilities.CreateTestNetState(); + SetTickCount(1000); + + // Simulate stable low-latency RTT measurements + var rttValues = new long[] { 50, 48, 52, 49, 51, 50, 48, 52 }; + + ns._rttHistory = new long[8]; + for (var i = 0; i < rttValues.Length; i++) + { + ns._rttHistory[i] = rttValues[i]; + } + ns._rttSampleCount = 8; + + // Calculate expected variance + long sum = 0, sumSq = 0; + foreach (var rtt in rttValues) + { + sum += rtt; + sumSq += rtt * rtt; + } + var expectedMean = sum / 8; + var expectedVariance = sumSq / 8 - expectedMean * expectedMean; + + // Clamp negative variance (safety check) + if (expectedVariance < 0) + { + expectedVariance = 0; + } + + ns._rttVariance = expectedVariance; + + Assert.True(ns._rttVariance >= 0, "Variance should never be negative"); + Assert.True(ns._rttVariance < 2500, "Low-variance samples should have variance < 2500"); + Assert.True(ns.AverageRtt > 0 && ns.AverageRtt < 200, "Average RTT should be ~50ms"); + Assert.True(ns.HasStableConnection, "Should be considered stable connection"); + } + + /// + /// Scenario: Actual speed hacker with stable connection. + /// Should be detected with high confidence. + /// + [Fact] + public void SpeedHack_StableConnection_DetectedDefinite() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // Set up stable, low-latency RTT + ns._rttHistory = new long[] { 30, 32, 28, 31, 29, 30, 31, 30 }; + ns._rttSampleCount = 8; + + long sum = 0, sumSq = 0; + for (var i = 0; i < 8; i++) + { + sum += ns._rttHistory[i]; + sumSq += ns._rttHistory[i] * ns._rttHistory[i]; + } + ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8); + if (ns._rttVariance < 0) + { + ns._rttVariance = 0; + } + + Assert.True(ns.HasStableConnection, "Should be stable for this test"); + + // Simulate speed hack: 50% faster movement + SimulateMovements(ns, + (67, 100), (67, 100), (67, 100), (67, 100), (67, 100), + (67, 100), (67, 100), (67, 100), (67, 100), (67, 100), + (67, 100), (67, 100), (67, 100), (67, 100), (67, 100) + ); + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); + + Assert.True(rate > 1.4f, $"Expected rate > 1.4 (50% speed hack), got {rate}"); + Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); + Assert.True(confidence > 0.5f, $"Expected high confidence, got {confidence}"); + } + + /// + /// Scenario: Borderline speed hack (7% faster) with unstable connection. + /// Should give benefit of doubt (not Definite). + /// + [Fact] + public void BorderlineSpeed_UnstableConnection_BenefitOfDoubt() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // Set up unstable connection + ns._rttHistory = new long[] { 50, 300, 80, 250, 100, 400, 60, 350 }; + ns._rttSampleCount = 8; + + long sum = 0, sumSq = 0; + for (var i = 0; i < 8; i++) + { + sum += ns._rttHistory[i]; + sumSq += ns._rttHistory[i] * ns._rttHistory[i]; + } + ns._rttVariance = sumSq / 8 - (sum / 8) * (sum / 8); + + Assert.False(ns.HasStableConnection, "Should be unstable due to high variance"); + + // Simulate borderline speed (7% faster) + SimulateMovements(ns, + (93, 100), (93, 100), (93, 100), (93, 100), (93, 100), + (93, 100), (93, 100), (93, 100), (93, 100), (93, 100) + ); + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out _, out var confidence); + + Assert.True(rate > 1.05f && rate < 1.10f, $"Expected rate ~1.07, got {rate}"); + + // Unstable connection + borderline rate = should NOT be Definite + Assert.True( + verdict != MovementThrottle.DetectionVerdict.Definite, + $"Borderline case with unstable connection should not be Definite. Verdict={verdict}, Confidence={confidence}" + ); + } + + /// + /// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration. + /// + [Fact] + public void MultipleLagSpikes_NoAccumulatedSuspicion() + { + var ns = PacketTestUtilities.CreateTestNetState(); + SetTickCount(0); + + // Set up history buffer with mostly normal movements and a burst at the end + ns._movementHistory = new NetState.MovementRecord[20]; + + // Fill with 17 normal movements (100ms intervals) + for (var i = 0; i < 17; i++) + { + ns._movementHistory[i] = new NetState.MovementRecord + { + Interval = 100, + TargetSpeed = 100, + QueueDepth = 0, + Flags = 0 + }; + } + + // Add 3 burst movements at the end (10ms intervals = burst) + for (var i = 17; i < 20; i++) + { + ns._movementHistory[i] = new NetState.MovementRecord + { + Interval = 10, + TargetSpeed = 100, + QueueDepth = 0, + Flags = 0 + }; + } + + ns._movementHistoryIndex = 0; // Next write position (wrapped) + ns._movementHistoryFull = true; // Buffer is full + + // Set gap duration (simulating a lag spike before the burst) + ns._lastGapDuration = 2500; + + // Analyze - burst forgiveness should trigger + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var samples, out var confidence); + + // Key assertions: + // 1. Gap duration should be cleared (burst forgiveness triggered) + Assert.Equal(0, ns._lastGapDuration); + + // 2. Confidence should be reduced by 0.3x multiplier + Assert.True( + confidence < 0.5f, + $"Burst after gap should have low confidence. Confidence={confidence}, Rate={rate}" + ); + + // 3. Should NOT be Definite despite the burst (forgiveness applied) + Assert.True( + verdict != MovementThrottle.DetectionVerdict.Definite, + $"Should not be Definite after burst forgiveness. Verdict={verdict}" + ); + } + + /// + /// Scenario: Walking vs running speed differences. + /// Walking (200ms interval) vs mounted running (100ms interval) should both + /// calculate correct rates. + /// + [Fact] + public void WalkingVsRunning_CorrectRateCalculation() + { + // Test walking (200ms expected interval) + var nsWalking = PacketTestUtilities.CreateTestNetState(); + SimulateMovements(nsWalking, + (200, 200), (200, 200), (200, 200), (200, 200), (200, 200), + (200, 200), (200, 200), (200, 200), (200, 200), (200, 200) + ); + + var walkingRate = MovementThrottle.CalculateMovementRate(nsWalking, out var walkingSamples); + Assert.True(walkingSamples >= 8, "Should have enough walking samples"); + Assert.True(walkingRate >= 0.95f && walkingRate <= 1.05f, + $"Walking rate should be ~1.0, got {walkingRate}"); + + // Test mounted running (100ms expected interval) + var nsRunning = PacketTestUtilities.CreateTestNetState(); + SimulateMovements(nsRunning, + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100) + ); + + var runningRate = MovementThrottle.CalculateMovementRate(nsRunning, out var runningSamples); + Assert.True(runningSamples >= 8, "Should have enough running samples"); + Assert.True(runningRate >= 0.95f && runningRate <= 1.05f, + $"Running rate should be ~1.0, got {runningRate}"); + + // Test speed hack while walking + var nsWalkingHack = PacketTestUtilities.CreateTestNetState(); + SimulateMovements(nsWalkingHack, + (100, 200), (100, 200), (100, 200), (100, 200), (100, 200), + (100, 200), (100, 200), (100, 200), (100, 200), (100, 200) + ); + + var walkingHackRate = MovementThrottle.CalculateMovementRate(nsWalkingHack, out _); + Assert.True(walkingHackRate >= 1.9f && walkingHackRate <= 2.1f, + $"Walking at running speed should be rate ~2.0, got {walkingHackRate}"); + } +} diff --git a/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs b/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs new file mode 100644 index 000000000..eca2ee0bf --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs @@ -0,0 +1,385 @@ +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Unit tests for MovementThrottle rate calculation and detection logic. +/// These tests are CI/CD safe - they don't depend on real timing. +/// +[Collection("Sequential Server Tests")] +public class MovementThrottleTests +{ + /// + /// Creates a test NetState with movement history pre-populated. + /// + private static NetState CreateNetStateWithHistory(params (short interval, ushort targetSpeed)[] records) + { + var ns = PacketTestUtilities.CreateTestNetState(); + + if (records.Length == 0) + { + return ns; + } + + // Initialize history buffer + ns._movementHistory = new NetState.MovementRecord[20]; + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = false; + + foreach (var (interval, targetSpeed) in records) + { + ns._movementHistory[ns._movementHistoryIndex] = new NetState.MovementRecord + { + Interval = interval, + TargetSpeed = targetSpeed, + QueueDepth = 0, + Flags = 0 + }; + ns._movementHistoryIndex++; + + if (ns._movementHistoryIndex >= 20) + { + ns._movementHistoryIndex = 0; + ns._movementHistoryFull = true; + } + } + + return ns; + } + + [Fact] + public void CalculateMovementRate_NoHistory_ReturnsOne() + { + var ns = CreateNetStateWithHistory(); + + var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount); + + Assert.Equal(1.0f, rate); + Assert.Equal(0, sampleCount); + } + + [Fact] + public void CalculateMovementRate_InsufficientSamples_ReturnsOne() + { + // Less than 8 samples (minSamplesForRate default) + var ns = CreateNetStateWithHistory( + (100, 100), + (100, 100), + (100, 100) + ); + + var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount); + + Assert.Equal(1.0f, rate); + // When insufficient samples, method returns early with sampleCount = 0 + Assert.Equal(0, sampleCount); + } + + [Fact] + public void CalculateMovementRate_NormalSpeed_ReturnsOne() + { + // 10 samples at exactly expected speed (100ms interval, 100ms target) + var ns = CreateNetStateWithHistory( + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100) + ); + + var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount); + + Assert.Equal(1.0f, rate); + Assert.Equal(10, sampleCount); + } + + [Fact] + public void CalculateMovementRate_SpeedHack_ReturnsHighRate() + { + // Moving at 50ms intervals when 100ms is expected = 2x speed + var ns = CreateNetStateWithHistory( + (50, 100), (50, 100), (50, 100), (50, 100), (50, 100), + (50, 100), (50, 100), (50, 100), (50, 100), (50, 100) + ); + + var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount); + + Assert.Equal(2.0f, rate); + Assert.Equal(10, sampleCount); + } + + [Fact] + public void CalculateMovementRate_SlowMovement_ReturnsLowRate() + { + // Moving at 200ms intervals when 100ms is expected = 0.5x speed + var ns = CreateNetStateWithHistory( + (200, 100), (200, 100), (200, 100), (200, 100), (200, 100), + (200, 100), (200, 100), (200, 100), (200, 100), (200, 100) + ); + + var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount); + + Assert.Equal(0.5f, rate); + Assert.Equal(10, sampleCount); + } + + [Fact] + public void CalculateMovementRate_MixedSpeeds_ReturnsAverageRate() + { + // Mix of mounted running (100ms target) at varying speeds + // Total target: 1000ms, Total actual: 900ms = 1.11 rate + var ns = CreateNetStateWithHistory( + (80, 100), (100, 100), (90, 100), (100, 100), (80, 100), + (90, 100), (100, 100), (80, 100), (90, 100), (90, 100) + ); + + var rate = MovementThrottle.CalculateMovementRate(ns, out var sampleCount); + + // 1000 / 900 = 1.111... + Assert.True(rate > 1.10f && rate < 1.12f, $"Expected rate ~1.11, got {rate}"); + Assert.Equal(10, sampleCount); + } + + [Fact] + public void DetectRecentBurst_NoBurst_ReturnsZero() + { + // Normal intervals, no burst + var ns = CreateNetStateWithHistory( + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100) + ); + + var (burstSize, precedingGap) = MovementThrottle.DetectRecentBurst(ns); + + Assert.Equal(0, burstSize); + } + + [Fact] + public void DetectRecentBurst_BurstDetected_ReturnsBurstSizeAndGap() + { + // Last 4 packets arrived in burst (< 15ms intervals), preceded by 500ms gap + var ns = CreateNetStateWithHistory( + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (500, 100), (5, 100), (5, 100), (5, 100), (5, 100) + ); + + var (burstSize, precedingGap) = MovementThrottle.DetectRecentBurst(ns); + + Assert.Equal(4, burstSize); + Assert.Equal(500, precedingGap); + } + + [Fact] + public void DetectRecentBurst_SmallBurst_DetectsCorrectly() + { + // 2 packets in burst + var ns = CreateNetStateWithHistory( + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (100, 100), (100, 100), (100, 100), (200, 100), (10, 100) + ); + + var (burstSize, precedingGap) = MovementThrottle.DetectRecentBurst(ns); + + Assert.Equal(1, burstSize); + Assert.Equal(200, precedingGap); + } + + [Fact] + public void AnalyzeMovement_NormalMovement_ReturnsNormal() + { + var ns = CreateNetStateWithHistory( + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100) + ); + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); + + Assert.Equal(MovementThrottle.DetectionVerdict.Normal, verdict); + Assert.Equal(1.0f, rate); + Assert.Equal(10, sampleCount); + } + + [Fact] + public void AnalyzeMovement_ClearSpeedHack_ReturnsDefinite() + { + // 30% faster than expected - clear speed hack + var ns = CreateNetStateWithHistory( + (77, 100), (77, 100), (77, 100), (77, 100), (77, 100), + (77, 100), (77, 100), (77, 100), (77, 100), (77, 100) + ); + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); + + Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); + Assert.True(rate > 1.25f, $"Expected rate > 1.25, got {rate}"); + } + + [Fact] + public void AnalyzeMovement_ModerateSpeedHack_ReturnsPossibleOrLikely() + { + // 8% faster than expected - moderate, should be Possible or Likely + var ns = CreateNetStateWithHistory( + (93, 100), (93, 100), (93, 100), (93, 100), (93, 100), + (93, 100), (93, 100), (93, 100), (93, 100), (93, 100) + ); + + // Set up stable, low-latency RTT (required for Likely verdict path) + ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; + ns._rttSampleCount = 8; + ns._rttVariance = 4; // Low variance + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); + + Assert.True( + verdict == MovementThrottle.DetectionVerdict.Possible || + verdict == MovementThrottle.DetectionVerdict.Likely, + $"Expected Possible or Likely, got {verdict}. Rate={rate}, Confidence={confidence}" + ); + Assert.True(rate > 1.05f && rate < 1.15f, $"Expected rate ~1.07, got {rate}"); + } + + [Fact] + public void AnalyzeMovement_BurstAfterLargeGap_ReducesConfidence() + { + // Simulate lag recovery: large gap followed by burst of packets + var ns = CreateNetStateWithHistory( + (100, 100), (100, 100), (100, 100), (100, 100), (100, 100), + (5, 100), (5, 100), (5, 100), (5, 100), (5, 100) + ); + + // Set up the gap duration (simulates what RecordMovement does) + ns._lastGapDuration = 1500; // Gap > maxChainGap/2 (1000) + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); + + // Even though rate is high, confidence should be reduced due to burst forgiveness + // The gap duration should trigger the forgiveness path + Assert.True(confidence < 0.5f, $"Expected low confidence due to burst forgiveness, got {confidence}"); + + // After analysis, gap duration should be reset + Assert.Equal(0, ns._lastGapDuration); + } + + [Fact] + public void AnalyzeMovement_HighQueue_IncreasesConfidence() + { + var ns = CreateNetStateWithHistory( + (95, 100), (95, 100), (95, 100), (95, 100), (95, 100), + (95, 100), (95, 100), (95, 100), (95, 100), (95, 100) + ); + + // Simulate high queue depth (modified client indicator) + ns._movementQueue = new System.Collections.Generic.Queue(); + for (var i = 0; i < 5; i++) + { + ns._movementQueue.Enqueue(new NetState.QueuedMovement()); + } + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); + + // Queue > 4 indicates modified client - should be Definite + Assert.Equal(MovementThrottle.DetectionVerdict.Definite, verdict); + } + + [Fact] + public void AnalyzeMovement_StableConnectionLowLatency_IncreasesConfidence() + { + var ns = CreateNetStateWithHistory( + (95, 100), (95, 100), (95, 100), (95, 100), (95, 100), + (95, 100), (95, 100), (95, 100), (95, 100), (95, 100) + ); + + // Set up stable, low-latency RTT + ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; + ns._rttSampleCount = 8; + ns._rttVariance = 4; // Low variance + + var verdict = MovementThrottle.AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); + + // With stable connection, even small anomalies are more suspicious + Assert.True(confidence > 0.2f, $"Expected higher confidence for stable connection, got {confidence}"); + } + + [Fact] + public void AnalyzeMovement_UnstableConnection_ReducesConfidence() + { + var ns = CreateNetStateWithHistory( + (90, 100), (90, 100), (90, 100), (90, 100), (90, 100), + (90, 100), (90, 100), (90, 100), (90, 100), (90, 100) + ); + + // Set up unstable connection (high variance) + ns._rttHistory = [50, 200, 80, 350, 100, 250, 90, 300]; + ns._rttSampleCount = 8; + ns._rttVariance = 15000; // High variance + + var verdictUnstable = MovementThrottle.AnalyzeMovement(ns, out _, out _, out var confidenceUnstable); + + // Reset and test with stable connection for comparison + var nsStable = CreateNetStateWithHistory( + (90, 100), (90, 100), (90, 100), (90, 100), (90, 100), + (90, 100), (90, 100), (90, 100), (90, 100), (90, 100) + ); + nsStable._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; + nsStable._rttSampleCount = 8; + nsStable._rttVariance = 4; + + var verdictStable = MovementThrottle.AnalyzeMovement(nsStable, out _, out _, out var confidenceStable); + + // Unstable connection should have lower confidence + Assert.True( + confidenceUnstable < confidenceStable, + $"Expected unstable ({confidenceUnstable}) < stable ({confidenceStable})" + ); + } + + [Fact] + public void HasStableConnection_LowLatencyLowVariance_ReturnsTrue() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // Stable: low variance, enough samples, low latency + ns._rttHistory = [50, 52, 48, 51, 49, 50, 51, 50]; + ns._rttSampleCount = 8; + ns._rttVariance = 4; // Low variance < 2500 + + Assert.True(ns.HasStableConnection); + } + + [Fact] + public void HasStableConnection_HighLatency_ReturnsFalse() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // High but consistent latency (500ms) - should NOT be considered stable + ns._rttHistory = [500, 502, 498, 501, 499, 500, 501, 500]; + ns._rttSampleCount = 8; + ns._rttVariance = 4; // Low variance, but latency > 200ms + + Assert.False(ns.HasStableConnection, "High latency connection should not be considered stable"); + } + + [Fact] + public void HasStableConnection_HighVariance_ReturnsFalse() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // Low average latency but high variance + ns._rttHistory = [50, 200, 30, 180, 40, 150, 60, 170]; + ns._rttSampleCount = 8; + ns._rttVariance = 5000; // High variance > 2500 + + Assert.False(ns.HasStableConnection); + } + + [Fact] + public void HasStableConnection_InsufficientSamples_ReturnsFalse() + { + var ns = PacketTestUtilities.CreateTestNetState(); + + // Only 2 samples + ns._rttHistory = [50, 50, 0, 0, 0, 0, 0, 0]; + ns._rttSampleCount = 2; + ns._rttVariance = 0; + + Assert.False(ns.HasStableConnection, "Needs at least 3 samples"); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPackets.cs b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPackets.cs index 8aff59cfa..2354098bf 100644 --- a/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPackets.cs +++ b/Projects/Server.Tests/Tests/Network/Packets/Outgoing/VendorSellPackets.cs @@ -1,18 +1,3 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: VendorSellPackets.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.Collections.Generic; namespace Server.Network; diff --git a/Projects/Server/FeatureFlags.cs b/Projects/Server/FeatureFlags.cs index 32916efa7..99d6f9226 100644 --- a/Projects/Server/FeatureFlags.cs +++ b/Projects/Server/FeatureFlags.cs @@ -9,4 +9,5 @@ public static class ServerFeatureFlags public static bool PlayerTrading { get; set; } = true; public static bool PvPCombat { get; set; } = true; public static bool BankAccess { get; set; } = true; + public static bool SpeedhackDetection { get; set; } } diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 9761919f2..cdd1174a7 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -100,9 +100,8 @@ public static class Core private static long _firstTick; - private static long _tickCount; - - // Make this available to unit tests for mocking + // Make these available to unit tests for mocking + internal static long _tickCount; internal static DateTime _now; public static long TickCount => _tickCount; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 0e2b02f3a..42bbc500e 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2522,7 +2522,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro if (ns != null) { - ns.Sequence = 0; + ns.ResetMovementState(); if (m_Map != null) { @@ -2758,7 +2758,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { if (sendUpdate) { - ourState.Sequence = 0; + ourState.ResetMovementState(); ourState.SendMobileUpdate(this); } @@ -4309,7 +4309,32 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro if (m_NetState != null) { - m_NetState._nextMovementTime += ComputeMovementSpeed(d); + var cost = ComputeMovementSpeed(d); + var now = Core.TickCount; + var delta = now - m_NetState._nextMovementTime; + + // Cap drift to prevent banking "lateness" for speed hacking later. + // maxDrift should match the credit buffer so the systems are symmetric. + const int maxDrift = 200; + + if (cost == 0) + { + // Direction-only turn: reset to now to prevent turn accumulation + m_NetState._nextMovementTime = now; + } + else + { + // Clamp _nextMovementTime so it's never more than maxDrift behind now. + // This limits how much "lateness" can be banked. + if (delta > maxDrift) + { + m_NetState._nextMovementTime = now - maxDrift; + } + + // Accumulative timing: add cost to current baseline + m_NetState._nextMovementTime += cost; + } + m_NetState.SendMovementAck(m_NetState.Sequence, this); } @@ -7249,7 +7274,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro if (isTeleport && (!m_NetState.HighSeas || !NoMoveHS)) { - m_NetState.Sequence = 0; + m_NetState.ResetMovementState(); m_NetState.SendMobileUpdate(this); } } diff --git a/Projects/Server/Network/MovementThrottle.cs b/Projects/Server/Network/MovementThrottle.cs index e29722b26..c28ef2228 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -14,56 +14,1142 @@ *************************************************************************/ 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 long _movementThrottleReset; // 1 second - private static long _throttleThreshold; // 400 milliseconds + 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() { - _movementThrottleReset = ServerConfiguration.GetOrUpdateSetting("movement.throttleReset", 1000); - _throttleThreshold = ServerConfiguration.GetOrUpdateSetting("movement.throttleThreshold", 400); + _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 + ); } - public static unsafe void Initialize() + /// + /// 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) { - IncomingPackets.RegisterThrottler(0x02, &Throttle); - } - - public static bool Throttle(int packetId, NetState ns) - { - var from = ns.Mobile; - - if (from?.Deleted != false || from.AccessLevel > AccessLevel.Player) + if (mobile?.Deleted != false) { - return 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; - var credit = ns._movementCredit; - var nextMove = ns._nextMovementTime; - // Reset system if idle for more than 1 second - if (now - nextMove + _movementThrottleReset > 0) + // 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._movementCredit = 0; - ns._nextMovementTime = now; - return false; + ns._lastQueueDepthCheck = now; + var verdict = CheckAndNotifyStaff(ns); + + // Adjust probe frequency based on detection verdict + ns.SetProbeFrequency((int)verdict); } - var cost = nextMove - now; + // Calculate timing delta: positive = on-time/late, negative = early + var delta = now - ns._nextMovementTime; - if (credit < cost) + // If there are already queued movements, add to queue to maintain order + if (ns._hasQueuedMovements) { - // Not enough credit, therefore throttled - return true; + QueueMovement(ns, dir, seq); + return; } - // On the next event loop, the player receives up to 400ms in grace latency - ns._movementCredit = Math.Min(_throttleThreshold, credit - cost); - return false; + // 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, + }; } } diff --git a/Projects/Server/Network/NetState/NetState.Movement.cs b/Projects/Server/Network/NetState/NetState.Movement.cs new file mode 100644 index 000000000..1cc79429e --- /dev/null +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -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 . * + *************************************************************************/ + +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; + + /// + /// Gets or sets whether movement/RTT logging is enabled for this connection. + /// When enabled, logs detailed RTT probe and response timing. + /// + 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 _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) + + /// + /// Resets movement state when sequence needs to be cleared (paralysis, teleport, map change, etc.) + /// + 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; + } + + /// + /// Tracks movement packet rate. Called for each movement packet received. + /// Returns the current rate (packets per second in the last window). + /// + 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; + } + + /// + /// Gets the current movement rate (packets in the current 1-second window). + /// + public int CurrentMovementRate => _movementsInWindow; + + /// + /// Gets the peak movement rate observed for this session. + /// + 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; + + /// + /// Sets the RTT probe interval based on suspicion level. + /// More suspicious = more frequent probes for better evidence. + /// + public void SetProbeFrequency(int suspicionLevel) + { + _rttProbeInterval = suspicionLevel switch + { + >= 3 => RttProbeIntervalDefinite, // Definite cheater + >= 2 => RttProbeIntervalSuspicious, // Likely cheater + _ => RttProbeIntervalNormal // Normal or Possible + }; + } + + /// + /// Sends an RTT probe if enough time has passed since the last one. + /// Called from movement validation when player is actively moving. + /// + 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(); + } + } + + /// + /// Records an RTT measurement when ClientVersion response is received. + /// + 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 + ); + } + } + + /// + /// Calculates the variance of RTT measurements for connection stability assessment. + /// + 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; + } + } + + /// + /// Gets the average RTT from recent measurements. + /// + 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; + } + } + + /// + /// 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). + /// + public bool HasStableConnection => + _rttSampleCount >= 3 && + _rttVariance < StableVarianceThreshold && + AverageRtt > 0 && + AverageRtt < MaxStableLatency; +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 1a9754647..8065cc997 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -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 diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 1e853aeac..c57be287c 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -63,9 +63,9 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode ns?.Send(stackalloc byte[] { 0xBD, 0x00, 0x03 }); + public static void SendClientVersionRequest(this NetState ns) => ns?.Send([0xBD, 0x00, 0x03]); /** * Packet: 0x85 diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index fcabea353..209779f24 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -304,6 +304,7 @@ public static class World Persistence.SerializeAll(); PauseSerializationThreads(); + EventSink.InvokeWorldSave(); } catch (Exception ex) @@ -384,6 +385,7 @@ public static class World { WorldState = WorldState.Running; Persistence.PostWorldSaveAll(); // Process decay and safety queues + MovementThrottle.ResetAllMovementTiming(); // Prevent post-save movement rejection bursts } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Commands/MovementDebugCommands.cs b/Projects/UOContent/Commands/MovementDebugCommands.cs new file mode 100644 index 000000000..090ada367 --- /dev/null +++ b/Projects/UOContent/Commands/MovementDebugCommands.cs @@ -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")}"); + } +} diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs index 213fea951..8b073b0cf 100644 --- a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs @@ -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, diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 70e5bae0d..07a444d03 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -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); diff --git a/Projects/UOContent/Network/Packets/IncomingMovementPackets.cs b/Projects/UOContent/Network/Packets/IncomingMovementPackets.cs index b72b8df88..380be80d6 100644 --- a/Projects/UOContent/Network/Packets/IncomingMovementPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingMovementPackets.cs @@ -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); } } diff --git a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs index 6e7a10f54..6da86be91 100644 --- a/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs @@ -360,7 +360,7 @@ public static class IncomingPlayerPackets from.SendEverything(); - state.Sequence = 0; + state.ResetMovementState(); } public static void PingReq(NetState state, SpanReader reader)