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:
parent
6745cf2075
commit
04d438239d
17 changed files with 2474 additions and 74 deletions
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
385
Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs
Normal file
385
Projects/Server.Tests/Tests/Network/MovementThrottleTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
389
Projects/Server/Network/NetState/NetState.Movement.cs
Normal file
389
Projects/Server/Network/NetState/NetState.Movement.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
94
Projects/UOContent/Commands/MovementDebugCommands.cs
Normal file
94
Projects/UOContent/Commands/MovementDebugCommands.cs
Normal 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")}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ public static class IncomingPlayerPackets
|
|||
|
||||
from.SendEverything();
|
||||
|
||||
state.Sequence = 0;
|
||||
state.ResetMovementState();
|
||||
}
|
||||
|
||||
public static void PingReq(NetState state, SpanReader reader)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue