ModernUO/Projects/Server/Network/NetState/NetState.Movement.cs
Kamron Batman 547c2ea0fa
fix: Fixes tick count wrap-around in movement throttle, and eliminates more allocations in NetState (#2603)
## Summary

Removes the per-tick allocation in the movement throttle, fixes tick-count wrap-around bugs in the throttle and RTT probe state, and trims per-connection allocations and dead fields in `NetState`.

## Movement throttle

- **No more per-tick `List<NetState>` snapshot.** `ProcessAllQueues()` iterates the `HashSet` directly and removes drained or disconnected states in place. `HashSet<T>.Remove` does not invalidate enumerators on .NET Core 3.0+ (verified on 10.0.11); only inserting a *new* member does, and the only `Add` is in the packet handler, which never nests with `Slice()`. The eager `Remove` calls in `RejectAndReset`, `ClearQueue`, and `ProcessMovementQueue` are gone; membership is reconciled once per tick from `_hasQueuedMovements`.
- **Debug logging** is now gated solely by the per-connection `NetState.MovementLogging` flag. The global `movementThrottle.debugLogging` setting is removed.
- **New settings**: `movementThrottle.maxRttBonus`, `movementThrottle.maxChainGap`, and `movementThrottle.speedHackNotificationCooldown` were fields with no config binding.

## Tick-count wrap-around

All comparisons are now in subtraction form and no tick field uses zero as a sentinel:

- `now < _nextMovementTime` in the queue drain loop → `now - _nextMovementTime < 0`.
- `_lastMovementRecordTime > 0`, `_lastSpeedHackNotification`, `_rttProbeTime > 0`, and `_nextRttProbe == 0` sentinels replaced with `_hasMovementRecord`, `_speedHackNotified`, `_rttProbePending`, and a seeded `_nextRttProbe`.
- `_lastQueueDepthCheck` and `_movementWindowStart` are seeded from `Core.TickCount` at construction and on reset instead of zero.

User-visible effects of the old code: on hosts with pass-through counters (GCP) movement history never recorded and speed hack detection was silently off; on every host, staff speed hack notifications were suppressed until `Core.TickCount` exceeded the five-minute cooldown.

## NetState

- `Instances` returns `HashSet<NetState>` again so engine-internal `foreach` uses the struct enumerator instead of boxing through `IReadOnlySet<T>`.
- Removed `_sustainedQueueDepth` (declared and zeroed since #2266, never read), `_lastRtt` (now derived as `LastRtt` from the newest history slot), and `_rttProbeTimestampHiRes` (only fed one debug log line). 20 bytes per connection.
- `HuePickers`, `Menus`, and `Trades` are lazily created instead of allocating three lists per connection, including every login-server connection that dies on shard select. `Trades` is released when it empties. All helpers and the `HuePickerResponse` / `MenuResponse` handlers are null-tolerant; the trade cancel loops keep their `i < Count` guards because `SecureTrade.Cancel()` runs virtual item hooks that can re-enter the same list.

## Testing

- `dotnet build -c Release` clean.
- All MovementThrottle tests pass (27), plus the Trade / Menu / HuePicker / NetState tests (32).
2026-09-01 20:25:20 -07:00

383 lines
14 KiB
C#

/*************************************************************************
* 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.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 long _lastQueueDepthCheck = Core.TickCount; // 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 (valid only when _hasMovementRecord)
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
// Detection state
internal int _consecutiveHighRateSeconds; // Sustained detection counter
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
internal bool _speedHackNotified; // False until the first notification is sent
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
// Movement packet rate tracking (for speed hack detection)
internal long _movementWindowStart = Core.TickCount; // 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;
// Reset movement history - next movement starts a new chain
_hasMovementRecord = false;
_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 = Core.TickCount;
_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 bool _rttProbePending; // True while waiting for a probe response
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
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 = Core.TickCount; // When to send next probe
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
/// <summary>
/// Gets the most recent RTT measurement, or 0 if none has been recorded.
/// </summary>
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
/// <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 (_rttProbePending)
{
// Timeout after 10 seconds - connection is probably dead or very laggy
if (now - _rttProbeTime > 10000)
{
_rttProbePending = false;
}
return;
}
// First probe: send immediately when player starts moving
// Subsequent probes: send when interval has passed
if (now - _nextRttProbe >= 0)
{
_rttProbePending = true;
_rttProbeTime = now;
_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 now = Core.TickCount;
if (!_rttProbePending)
{
// Not expecting a response (client-initiated version send) - ignore silently
return;
}
var rtt = now - _rttProbeTime;
if (_movementLogging)
{
movementLogger.Debug(
"[RTT-Response] {Account}: {Rtt}ms",
Account?.Username ?? _toString, rtt
);
}
_rttProbePending = false;
// 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;
// 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;
}