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)