// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.PlugIns; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.PlugIns; /// /// A feature plugin that provides configuration and acts as a trigger control for speedhack anti-cheat checks. /// [PlugIn] [Display(Name = "Speedhack Anti-Cheat", Description = "Detects and prevents player walk and attack speedhacking.")] [Guid("A95A8D2F-A0C3-442E-995C-005B5C1B42D2")] public class SpeedHackDetectPlugIn : IFeaturePlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, ISpeedHackCheatCheckPlugIn { private const int NormalStepDelayMs = 300; private readonly ConditionalWeakTable _playerStates = new(); /// public SpeedHackDetectConfiguration? Configuration { get; set; } /// public object CreateDefaultConfig() => new SpeedHackDetectConfiguration(); /// public async ValueTask WalkCheatCheckAsync(Player player, Memory steps, SpeedHackCheckEventArgs eventArgs) { if (steps.IsEmpty || IsServerControlled(player)) { return; } var config = this.Configuration; if (config is null) { return; } bool isSafezone = player.IsAtSafezone(); bool shouldRecordViolation = false; var startPoint = steps.Span[0].From; var state = this.GetState(player); lock (state.Lock) { if (isSafezone) { state.RecentWalks.Clear(); state.LastWalkStartTime = DateTime.MinValue; } else { var now = DateTime.UtcNow; if (state.LastWalkStartTime > DateTime.MinValue) { if (now - state.LastWalkStartTime > TimeSpan.FromSeconds(2)) { state.RecentWalks.Clear(); } } state.RecentWalks.Enqueue(new WalkHistoryEntry { Time = now, StartPoint = startPoint }); state.LastWalkStartTime = now; while (state.RecentWalks.Count > 5) { state.RecentWalks.Dequeue(); } if (state.RecentWalks.Count >= 3) { var first = state.RecentWalks.Peek(); var elapsed = now - first.Time; // Compute cumulative Chebyshev path length between consecutive start positions. var cumulativeTiles = 0; Point? prevPoint = null; foreach (var entry in state.RecentWalks) { if (prevPoint.HasValue) { cumulativeTiles += Math.Max( Math.Abs((int)entry.StartPoint.X - prevPoint.Value.X), Math.Abs((int)entry.StartPoint.Y - prevPoint.Value.Y)); } prevPoint = entry.StartPoint; } if (cumulativeTiles > 0) { double stepDelayMs = player.StepDelay.TotalMilliseconds; double scalingFactor = stepDelayMs / NormalStepDelayMs; const double BaseStepDelayMarginMs = 50.0; const double MinStepDelayMs = 50.0; double stepDelayMarginMs = BaseStepDelayMarginMs * scalingFactor; double checkStepDelayMs = Math.Max(Math.Min(MinStepDelayMs, stepDelayMs), stepDelayMs - stepDelayMarginMs); var expectedTime = TimeSpan.FromMilliseconds(cumulativeTiles * checkStepDelayMs); var deficit = expectedTime - elapsed; var tolerance = TimeSpan.FromMilliseconds(config.WalkSpeedToleranceMs * scalingFactor); if (deficit > tolerance) { player.Logger.LogWarning( "Speedhack detected on walk for player {0}: traveled {1} tiles in {2}ms (expected at least {3}ms). Deficit: {4}ms.", player.Name, cumulativeTiles, elapsed.TotalMilliseconds, expectedTime.TotalMilliseconds, deficit.TotalMilliseconds); shouldRecordViolation = true; state.RecentWalks.Clear(); // Clear to avoid double triggers state.LastWalkStartTime = DateTime.MinValue; // Reset tracker } } } } } if (shouldRecordViolation) { eventArgs.IsCheatDetected = true; await this.RecordViolationAsync(player, state, config).ConfigureAwait(false); } } /// public async ValueTask AttackCheatCheckAsync(Player player, SpeedHackCheckEventArgs eventArgs) { if (player.Attributes is not { } attributes || IsServerControlled(player)) { return; } var config = this.Configuration; if (config is null) { return; } var attackSpeed = attributes[Stats.AttackSpeed]; var now = DateTime.UtcNow; var minIntervalMs = Math.Max(config.AttackSpeedMinIntervalMs, config.AttackSpeedBaseDelayMs - (attackSpeed * config.AttackSpeedScalingFactor)); var state = this.GetState(player); bool shouldRecordViolation = false; lock (state.Lock) { if (state.LastAttackTokenUpdateTime == DateTime.MinValue) { state.LastAttackTokenUpdateTime = now; state.AttackTokens = config.MaxAttackTokens - 1.0; return; } var elapsedMs = (now - state.LastAttackTokenUpdateTime).TotalMilliseconds; state.LastAttackTokenUpdateTime = now; var regen = elapsedMs / minIntervalMs; state.AttackTokens = Math.Min(config.MaxAttackTokens, state.AttackTokens + regen); if (state.AttackTokens >= 1.0) { state.AttackTokens -= 1.0; return; } shouldRecordViolation = true; } if (shouldRecordViolation) { eventArgs.IsCheatDetected = true; await this.RecordViolationAsync(player, state, config).ConfigureAwait(false); } } /// public ValueTask ResetMovementStateAsync(Player player) { var state = this.GetState(player); lock (state.Lock) { state.LastWalkStartTime = DateTime.MinValue; state.RecentWalks.Clear(); } return ValueTask.CompletedTask; } /// /// Gets the warning count for the player (used in diagnostics/testing). /// /// The player. /// The warning count. public int GetWarningCount(Player player) { var state = this.GetState(player); lock (state.Lock) { return state.AlertTimes.Count; } } /// /// Sets the last alert time for the player (used in diagnostics/testing). /// /// The player. /// The time to set. public void SetLastAlertTime(Player player, DateTime time) { var state = this.GetState(player); lock (state.Lock) { state.LastAlertTime = time; } } /// /// Determines whether the player's actions originate on the server instead of a game client. /// These checks validate what a client claims about its own timing, so there is nothing to /// validate for an offline player: the server itself paces its walks and attacks. Its MU Helper /// tick performs the same work cycle as the original client helper (recover, then attack), which /// draws two attack tokens from one 500 ms tick and eventually empties the bucket - so leaving /// these players in would only ever produce false positives, and with the default configuration /// those are answered with a persisted account ban. /// /// The player. /// true if the player is controlled by the server; otherwise, false. private static bool IsServerControlled(Player player) => player is Offline.OfflinePlayer; private SpeedHackState GetState(Player player) { return this._playerStates.GetValue(player, p => new SpeedHackState(this.Configuration?.MaxAttackTokens ?? 5.0)); } private async ValueTask RecordViolationAsync(Player player, SpeedHackState state, SpeedHackDetectConfiguration config) { var now = DateTime.UtcNow; bool shouldBan = false; bool shouldWarn = false; bool shouldDisconnect = false; lock (state.Lock) { if (now - state.LastAlertTime < TimeSpan.FromSeconds(config.AlertDebounceSeconds)) { return; } state.LastAlertTime = now; state.AlertTimes.Enqueue(now); while (state.AlertTimes.Count > 0 && now - state.AlertTimes.Peek() > TimeSpan.FromHours(config.WarningHistoryHours)) { state.AlertTimes.Dequeue(); } player.Logger.LogWarning("Speedhack warning issued for player {0}. Total warnings in last hour: {1}", player.Name, state.AlertTimes.Count); if (state.AlertTimes.Count > config.MaxWarnings) { if (config.AutoBan) { player.Logger.LogError("Player {0} exceeded speedhack warning limit. Banning account {1} and disconnecting.", player.Name, player.Account?.LoginName); if (player.Account is { } account) { account.State = AccountState.Banned; shouldBan = true; } } if (config.DisconnectOnViolation) { shouldDisconnect = true; } if (!shouldBan && !shouldDisconnect) { shouldWarn = true; } } else { shouldWarn = true; } } if (shouldBan) { await player.SaveProgressAsync().ConfigureAwait(false); } if (shouldBan || shouldDisconnect) { await player.DisconnectAsync().ConfigureAwait(false); } else if (shouldWarn) { await player.ShowBlueMessageAsync("Warning: Unusual activity detected (speed check). Repeated violations will result in account restriction.").ConfigureAwait(false); } else { // Do nothing if no actions are required. } } private readonly record struct WalkHistoryEntry { public DateTime Time { get; init; } public Point StartPoint { get; init; } } private class SpeedHackState { public SpeedHackState(double maxAttackTokens) { this.AttackTokens = maxAttackTokens; } public object Lock { get; } = new(); public double AttackTokens { get; set; } public DateTime LastAttackTokenUpdateTime { get; set; } = DateTime.MinValue; public DateTime LastAlertTime { get; set; } = DateTime.MinValue; public Queue AlertTimes { get; } = new(); public Queue RecentWalks { get; } = new(); public DateTime LastWalkStartTime { get; set; } = DateTime.MinValue; } }