feat: Adds robust speed hack detection and movement throttling (#2266)

## Summary

   Server-side movement throttle that prevents speed hacking while accurately identifying cheaters with detection of lagging connections.

   **Key features:**
   - Credit buffer (200ms) absorbs timing jitter from legitimate players
   - Movement queue handles larger bursts, draining at proper game-tick intervals
   - RTT measurement distinguishes network lag from speed hacks
   - Queue depth detection catches ACK-throttled speed hacks (going straight)

   ## How It Works

   **Throttle** (prevention): Movements arriving too early either consume credit or get queued. The queue drains at
   correct intervals, so speed hackers can't move faster regardless of what they send.

   **Detection** (identification): Combines multiple signals to identify cheaters:
   | Signal | What it catches |
   |--------|-----------------|
   | Queue depth ≥4 sustained | ACK-throttled speed hacks (client limits unacked moves to 5) |
   | Movement rate >1.05x | Direction-change speed hacks where timing is visible |
   | Stable RTT + high queue | Eliminates false positives from laggy players |

   **RTT-Aware Logic:**
   - Probes only sent to players actively moving (event-driven, not global loop)
   - Stable low-latency + problems = suspicious
   - Unstable/high-latency + problems = probably just lag, throttle handles it

   ## Configuration

   ```json
   {
     "movementThrottle.maxCredit": 200,
     "movementThrottle.softQueueLimit": 6,
     "movementThrottle.hardQueueLimit": 10,
     "movementThrottle.debugLogging": false
   }
   ```
This commit is contained in:
Kamron Batman 2026-03-07 11:44:37 -08:00 committed by GitHub
parent 6745cf2075
commit 04d438239d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2474 additions and 74 deletions

View file

@ -0,0 +1,430 @@
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
/// <summary>
/// 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"
/// </summary>
[Collection("Sequential Server Tests")]
[Trait("Category", "Manual")]
public class MovementThrottleLatencyTests
{
/// <summary>
/// Sets the simulated tick count for testing.
/// </summary>
private static void SetTickCount(long ticks)
{
Core._tickCount = ticks;
}
/// <summary>
/// Simulates recording movements at specific intervals.
/// </summary>
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;
}
}
/// <summary>
/// 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
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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}"
);
}
/// <summary>
/// Scenario: RTT probing and measurement.
/// Verifies that RTT variance is calculated correctly and stability
/// is properly determined.
/// </summary>
[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");
}
/// <summary>
/// Scenario: Actual speed hacker with stable connection.
/// Should be detected with high confidence.
/// </summary>
[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}");
}
/// <summary>
/// Scenario: Borderline speed hack (7% faster) with unstable connection.
/// Should give benefit of doubt (not Definite).
/// </summary>
[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}"
);
}
/// <summary>
/// Scenario: Verifies that burst forgiveness triggers correctly and clears gap duration.
/// </summary>
[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}"
);
}
/// <summary>
/// Scenario: Walking vs running speed differences.
/// Walking (200ms interval) vs mounted running (100ms interval) should both
/// calculate correct rates.
/// </summary>
[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}");
}
}

View file

@ -0,0 +1,385 @@
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
/// <summary>
/// Unit tests for MovementThrottle rate calculation and detection logic.
/// These tests are CI/CD safe - they don't depend on real timing.
/// </summary>
[Collection("Sequential Server Tests")]
public class MovementThrottleTests
{
/// <summary>
/// Creates a test NetState with movement history pre-populated.
/// </summary>
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<NetState.QueuedMovement>();
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");
}
}

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Collections.Generic;
namespace Server.Network;

View file

@ -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; }
}

View file

@ -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;

View file

@ -2522,7 +2522,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
if (ns != null)
{
ns.Sequence = 0;
ns.ResetMovementState();
if (m_Map != null)
{
@ -2758,7 +2758,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
{
if (sendUpdate)
{
ourState.Sequence = 0;
ourState.ResetMovementState();
ourState.SendMobileUpdate(this);
}
@ -4309,7 +4309,32 @@ public partial class Mobile : IHued, IComparable<Mobile>, 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<Mobile>, ISpawnable, IObjectPro
if (isTeleport && (!m_NetState.HighSeas || !NoMoveHS))
{
m_NetState.Sequence = 0;
m_NetState.ResetMovementState();
m_NetState.SendMobileUpdate(this);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Server.Logging;
namespace Server.Network;
public partial class NetState
{
private static readonly ILogger movementLogger = LogFactory.GetLogger(typeof(NetState));
// Per-connection movement logging (RTT instrumentation, etc.)
// Can be enabled at runtime for specific connections
internal bool _movementLogging;
/// <summary>
/// Gets or sets whether movement/RTT logging is enabled for this connection.
/// When enabled, logs detailed RTT probe and response timing.
/// </summary>
public bool MovementLogging
{
get => _movementLogging;
set => _movementLogging = value;
}
internal struct QueuedMovement
{
public Direction Direction;
public int Sequence;
}
// Movement history record for rate calculation (8 bytes, cache-aligned)
[StructLayout(LayoutKind.Sequential, Pack = 2)]
internal struct MovementRecord
{
public short Interval; // Time since previous packet (ms), capped at 32767
public ushort TargetSpeed; // Expected interval (100ms mounted running, etc.)
public byte QueueDepth; // Queue size when received
public byte Flags; // MovementRecordFlags
public short Reserved; // Padding to 8 bytes for cache alignment
}
[Flags]
internal enum MovementRecordFlags : byte
{
None = 0,
Running = 1,
Mounted = 2,
DirectionChangeOnly = 4, // Cost was 0 (turn in place)
WasQueued = 8 // Packet was queued, not executed immediately
}
// Movement queue state
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
internal long _movementCredit; // Credit buffer for timing jitter
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
internal int _sustainedQueueDepth; // Tracks sustained high queue depth
internal long _lastQueueDepthCheck; // Throttle depth check frequency
internal bool _hasQueuedMovements; // Fast check for Slice()
// Movement history for rate-based speed hack detection (lazy initialized)
internal MovementRecord[] _movementHistory; // Circular buffer
internal int _movementHistoryIndex; // Next write position (also serves as count until full)
internal bool _movementHistoryFull; // True once buffer has wrapped
internal long _lastMovementRecordTime; // For calculating intervals
// Detection state
internal int _consecutiveHighRateSeconds; // Sustained detection counter
internal long _lastSpeedHackNotification; // Rate-limit notifications
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
// Movement packet rate tracking (for speed hack detection)
internal long _movementWindowStart; // Start of current 1-second window
internal int _movementsInWindow; // Count in current window
internal int _peakMovementRate; // Highest rate seen (packets/sec)
/// <summary>
/// Resets movement state when sequence needs to be cleared (paralysis, teleport, map change, etc.)
/// </summary>
public void ResetMovementState()
{
_movementQueue?.Clear();
Sequence = 0;
_nextMovementTime = Core.TickCount;
_movementCredit = 0;
_hasQueuedMovements = false;
_sustainedQueueDepth = 0;
// Reset movement history - next movement starts a new chain
_lastMovementRecordTime = 0;
_movementHistoryIndex = 0;
_movementHistoryFull = false;
// Reset detection state - sustained detection loses context on teleport/map change
_consecutiveHighRateSeconds = 0;
_lastGapDuration = 0;
_rttProbeInterval = RttProbeIntervalNormal;
// Reset packet rate window
_movementWindowStart = 0;
_movementsInWindow = 0;
}
/// <summary>
/// Tracks movement packet rate. Called for each movement packet received.
/// Returns the current rate (packets per second in the last window).
/// </summary>
public int TrackMovementRate()
{
var now = Core.TickCount;
// Check if we're in a new 1-second window
if (now - _movementWindowStart >= 1000)
{
// Record peak rate if this window had movements
if (_movementsInWindow > _peakMovementRate)
{
_peakMovementRate = _movementsInWindow;
}
// Start new window
_movementWindowStart = now;
_movementsInWindow = 1;
return 1;
}
// Same window, increment count
_movementsInWindow++;
return _movementsInWindow;
}
/// <summary>
/// Gets the current movement rate (packets in the current 1-second window).
/// </summary>
public int CurrentMovementRate => _movementsInWindow;
/// <summary>
/// Gets the peak movement rate observed for this session.
/// </summary>
public int PeakMovementRate => _peakMovementRate;
// RTT Measurement Configuration
private const int RttProbeIntervalNormal = 5000; // Normal: probe every 5 seconds
private const int RttProbeIntervalSuspicious = 2000; // Suspicious: probe every 2 seconds
private const int RttProbeIntervalDefinite = 1000; // Definite cheater: probe every 1 second
private const int RttProbeJitter = 500; // Random jitter to prevent bursts
private const int RttHistorySize = 8; // Keep 8 samples
private const long StableVarianceThreshold = 2500; // Variance < 50ms std dev = stable
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
// RTT state
internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
internal long _lastRtt; // Most recent RTT measurement
internal long[] _rttHistory; // Rolling history (lazy init)
internal int _rttHistoryIndex; // Current position in history
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
internal long _rttVariance; // Calculated variance for stability
internal long _nextRttProbe; // When to send next probe
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
private long _rttProbeTimestampHiRes;
/// <summary>
/// Sets the RTT probe interval based on suspicion level.
/// More suspicious = more frequent probes for better evidence.
/// </summary>
public void SetProbeFrequency(int suspicionLevel)
{
_rttProbeInterval = suspicionLevel switch
{
>= 3 => RttProbeIntervalDefinite, // Definite cheater
>= 2 => RttProbeIntervalSuspicious, // Likely cheater
_ => RttProbeIntervalNormal // Normal or Possible
};
}
/// <summary>
/// Sends an RTT probe if enough time has passed since the last one.
/// Called from movement validation when player is actively moving.
/// </summary>
public void MaybeSendRttProbe()
{
// Only probe logged-in players
if (Mobile?.Deleted != false)
{
return;
}
var now = Core.TickCount;
// Don't send if we're still waiting for a response
if (_rttProbeTime > 0)
{
// Timeout after 10 seconds - connection is probably dead or very laggy
if (now - _rttProbeTime > 10000)
{
_rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
}
return;
}
// First probe: send immediately when player starts moving
// Subsequent probes: send when interval has passed
if (_nextRttProbe == 0 || now >= _nextRttProbe)
{
_rttProbeTime = now;
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Probe] {Account}: Sending probe at TickCount={TickCount}",
Account?.Username ?? _toString, now
);
}
this.SendClientVersionRequest();
}
}
/// <summary>
/// Records an RTT measurement when ClientVersion response is received.
/// </summary>
public void RecordRttMeasurement()
{
var nowHiRes = Stopwatch.GetTimestamp();
var now = Core.TickCount;
if (_rttProbeTime <= 0)
{
// Not expecting a response (client-initiated version send) - ignore silently
return;
}
var rtt = now - _rttProbeTime;
// High-resolution RTT in microseconds
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
);
}
_rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
// Sanity check - RTT should be positive and reasonable
if (rtt is <= 0 or > 10000)
{
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Response] {Account}: Invalid RTT {Rtt}ms, discarding",
Account?.Username ?? _toString, rtt
);
}
return;
}
// Lazy init history
_rttHistory ??= new long[RttHistorySize];
// Update history
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
_lastRtt = rtt;
// Track sample count (saturates at buffer size)
if (_rttSampleCount < RttHistorySize)
{
_rttSampleCount++;
}
// Recalculate variance
UpdateRttVariance();
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Response] {Account}: Recorded RTT={Rtt}ms, Avg={Avg}ms, Var={Var}, Samples={Samples}, Stable={Stable}",
Account?.Username ?? _toString, rtt, AverageRtt, _rttVariance, _rttSampleCount, HasStableConnection
);
}
}
/// <summary>
/// Calculates the variance of RTT measurements for connection stability assessment.
/// </summary>
private void UpdateRttVariance()
{
if (_rttHistory == null)
{
_rttVariance = 0;
return;
}
long sum = 0;
long sumSq = 0;
int count = 0;
for (int i = 0; i < RttHistorySize; i++)
{
var sample = _rttHistory[i];
if (sample > 0)
{
sum += sample;
sumSq += sample * sample;
count++;
}
}
if (count < 2)
{
_rttVariance = 0;
return;
}
var mean = sum / count;
_rttVariance = sumSq / count - mean * mean;
// Safety clamp: integer division rounding can produce negative variance
if (_rttVariance < 0)
{
_rttVariance = 0;
}
}
/// <summary>
/// Gets the average RTT from recent measurements.
/// </summary>
public long AverageRtt
{
get
{
if (_rttHistory == null)
{
return 0;
}
long sum = 0;
int count = 0;
for (int i = 0; i < RttHistorySize; i++)
{
var sample = _rttHistory[i];
if (sample > 0)
{
sum += sample;
count++;
}
}
return count > 0 ? sum / count : 0;
}
}
/// <summary>
/// Returns true if the connection has stable, low-variance, low-latency connection.
/// Requires at least 3 samples to make a stability determination.
/// Checks both variance (consistency) and absolute latency (quality).
/// </summary>
public bool HasStableConnection =>
_rttSampleCount >= 3 &&
_rttVariance < StableVarianceThreshold &&
AverageRtt > 0 &&
AverageRtt < MaxStableLatency;
}

View file

@ -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

View file

@ -63,9 +63,9 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
// Managed socket with buffers (handles lifecycle automatically)
internal RingSocket _socket;
// Speed Hack Prevention
internal long _movementCredit;
internal long _nextMovementTime;
// General packet throttle state (used for other throttled packets)
internal bool _isThrottled;
private IAccount _account;
internal enum ParserState
@ -942,6 +942,14 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
}
else
{
// Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
// The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
if (_account != null && Mobile == null)
{
this.SendClientVersionRequest();
return;
}
LogInfo("Disconnecting due to inactivity...");
Disconnect("Disconnecting due to inactivity.");
}

View file

@ -109,7 +109,7 @@ public static class OutgoingAccountPackets
* Sends a requests for the client version
*/
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void SendClientVersionRequest(this NetState ns) => ns?.Send(stackalloc byte[] { 0xBD, 0x00, 0x03 });
public static void SendClientVersionRequest(this NetState ns) => ns?.Send([0xBD, 0x00, 0x03]);
/**
* Packet: 0x85

View file

@ -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)]

View file

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

View file

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

View file

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

View file

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

View file

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