//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.Tests;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence.InMemory;
using MUnique.OpenMU.PlugIns;
using MUnique.OpenMU.GameLogic.PlugIns;
using NUnit.Framework;
///
/// Tests for the speed hack anti-cheat detection logic.
///
[TestFixture]
public class SpeedHackAntiCheatTests
{
private static readonly Point StartPoint = new(100, 100);
///
/// Tests that the speed check detects speed hacks on walk and bans the account after exceeding limits.
///
[Test]
public async Task TestWalkSpeedHackDetectionBansAccountAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
// Perform rapid walks to exceed the 3-warnings limit.
// We need 12 iterations because speedhack detection uses a rolling history of recent walks
// which requires 3 walks to trigger the first warning. Since the history is cleared on violation,
// we need 3 walks * 4 warnings = 12 iterations to trigger account ban.
for (int i = 0; i < 12; i++)
{
var nextFrom = new Point((byte)(StartPoint.X + i * 2), StartPoint.Y);
var nextTo = new Point((byte)(StartPoint.X + i * 2 + 2), StartPoint.Y);
// Set player position to nextFrom to avoid startOffset validation check rejecting the walk.
player.Position = nextFrom;
// Reset the alert debounce to allow consecutive warnings.
player.GameContext.FeaturePlugIns.GetPlugIn()!.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// The account should be banned because we sent multiple walk requests triggering 4 warnings
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Banned));
}
///
/// Tests that a large walk start offset triggers rubberbanding and doesn't record a speed hack warning.
///
[Test]
public async Task TestWalkStartOffsetRubberbandsPlayerAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
player.Position = StartPoint;
var startPointTooFar = new Point((byte)(StartPoint.X + 10), StartPoint.Y);
var targetPoint = new Point((byte)(StartPoint.X + 12), StartPoint.Y);
WalkingStep[] steps = [new() { From = startPointTooFar, To = targetPoint, Direction = Direction.East }];
await player.WalkToAsync(targetPoint, steps).ConfigureAwait(false);
// Verify that no violation was recorded
Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(player), Is.EqualTo(0));
// Verify that the view plugin container for IObjectMovedPlugIn was called to warp the player back
var objectMovedPlugIn = player.ViewPlugIns.GetPlugIn();
Assert.That(objectMovedPlugIn, Is.Not.Null);
var objectMovedMock = Mock.Get(objectMovedPlugIn!);
objectMovedMock.Verify(m => m.ObjectMovedAsync(player, MoveType.Instant), Times.Once);
}
///
/// Tests that walking in a safe zone does not trigger speed hack warnings/violations.
///
[Test]
public async Task TestWalkSpeedHackBypassedInSafezoneAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
// Mark current map terrain to have safe zone at the positions player walks through
var mapTerrain = player.CurrentMap!.Terrain;
for (int x = 0; x < mapTerrain.SafezoneMap.GetLength(0); x++)
{
for (int y = 0; y < mapTerrain.SafezoneMap.GetLength(1); y++)
{
mapTerrain.SafezoneMap[x, y] = true;
}
}
// Verify that IsAtSafezone returns true
Assert.That(player.IsAtSafezone(), Is.True);
// Perform rapid walks to show it is bypassed.
for (int i = 0; i < 12; i++)
{
var nextFrom = new Point((byte)(StartPoint.X + i * 2), StartPoint.Y);
var nextTo = new Point((byte)(StartPoint.X + i * 2 + 2), StartPoint.Y);
player.Position = nextFrom;
player.GameContext.FeaturePlugIns.GetPlugIn()!.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// Account should remain normal
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(player), Is.EqualTo(0));
}
///
/// Tests that attacking too fast triggers attack speed hack detection.
///
[Test]
public async Task TestAttackSpeedHackDetectionAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
player.Attributes![Stats.AttackSpeed] = 20;
var speedCheck = player.GameContext.PlugInManager.GetPlugInPoint();
Assert.That(speedCheck, Is.Not.Null);
// The first attack sets the timestamp tracker
var argsFirst = new SpeedHackCheckEventArgs();
await speedCheck!.AttackCheatCheckAsync(player, argsFirst).ConfigureAwait(false);
Assert.That(argsFirst.IsCheatDetected, Is.False);
// Perform rapid attacks in succession (0ms elapsed)
// With attack speed 20, min expected interval is 1000 - 60 = 940ms.
// Doing this multiple times should trigger the rolling average detection.
bool detectedHack = false;
for (int i = 0; i < 5; i++)
{
var args = new SpeedHackCheckEventArgs();
await speedCheck.AttackCheatCheckAsync(player, args).ConfigureAwait(false);
if (args.IsCheatDetected)
{
detectedHack = true;
break;
}
}
Assert.That(detectedHack, Is.True);
}
///
/// Tests that teleporting/warping to a different coordinate clears recent walks
/// and doesn't trigger speed hack warnings on subsequent movements.
///
[Test]
public async Task TestWarpClearsWalkHistoryAndPreventsFalsePositiveAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
player.Position = StartPoint;
// Perform a normal walk
WalkingStep[] steps1 = [new() { From = StartPoint, To = new Point((byte)(StartPoint.X + 1), StartPoint.Y), Direction = Direction.East }];
await player.WalkToAsync(new Point((byte)(StartPoint.X + 1), StartPoint.Y), steps1).ConfigureAwait(false);
// Simulate instant warp to different coordinates far away (e.g. via WarpToAsync/PlaceAtGate)
var gate = new ExitGate
{
Map = player.CurrentMap!.Definition,
X1 = 200,
X2 = 200,
Y1 = 200,
Y2 = 200,
Direction = Direction.West
};
await player.WarpToAsync(gate).ConfigureAwait(false);
// Perform a walk immediately at the new coordinates
var newPosition = player.Position;
WalkingStep[] steps2 = [new() { From = newPosition, To = new Point((byte)(newPosition.X + 1), newPosition.Y), Direction = Direction.East }];
await player.WalkToAsync(new Point((byte)(newPosition.X + 1), newPosition.Y), steps2).ConfigureAwait(false);
// Verify that no violation was recorded
Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(player), Is.EqualTo(0));
}
///
/// Tests that a speedhacker using Cheat Engine (moving extremely fast without waiting for the server's Position to update)
/// is successfully detected and banned by the speed check.
///
[Test]
public async Task TestWalkSpeedHackDetectionWithCheatEngineAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
player.Position = StartPoint;
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
var objectMovedPlugIn = player.ViewPlugIns.GetPlugIn();
var objectMovedMock = Mock.Get(objectMovedPlugIn!);
Point currentClientPos = StartPoint;
objectMovedMock.Setup(m => m.ObjectMovedAsync(player, MoveType.Instant))
.Callback(() => currentClientPos = player.Position);
// Perform rapid walks to exceed the warnings limit.
// We run 16 iterations because warnings occur every 3rd step, followed by an offset desync rubberband on the 4th,
// requiring 16 iterations to reach 4 warnings and trigger account ban.
for (int i = 0; i < 16; i++)
{
var nextFrom = currentClientPos;
var nextTo = new Point((byte)(nextFrom.X + 2), nextFrom.Y);
currentClientPos = nextTo;
// Reset the alert debounce to allow consecutive warnings.
player.GameContext.FeaturePlugIns.GetPlugIn()!.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// The account should be banned because we sent rapid walks, even though the server position was out of sync.
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Banned));
}
///
/// Tests that a player equipped with a mount walking with Cheat Engine (rapid walk requests)
/// is successfully detected and banned.
///
[Test]
public async Task TestWalkSpeedHackWithMountDetectedAsync()
{
var mountItem = new Item
{
ItemSlot = MUnique.OpenMU.DataModel.InventoryConstants.PetSlot,
Definition = new ItemDefinition
{
Group = 13,
Number = 37, // Fenrir
}
};
var inventoryItems = new List- { mountItem };
var player = await CreatePlayerWithSpeedAttributesAsync(inventoryItems).ConfigureAwait(false);
player.Attributes![Stats.MovementSpeed] = 17;
Assert.That(player.StepDelay.TotalMilliseconds, Is.EqualTo(TimeSpan.FromMilliseconds(4000.0 / 17.0).TotalMilliseconds).Within(1.0));
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
var objectMovedPlugIn = player.ViewPlugIns.GetPlugIn();
var objectMovedMock = Mock.Get(objectMovedPlugIn!);
Point currentClientPos = StartPoint;
objectMovedMock.Setup(m => m.ObjectMovedAsync(player, MoveType.Instant))
.Callback(() => currentClientPos = player.Position);
// Perform rapid walks to exceed the warnings limit.
for (int i = 0; i < 16; i++)
{
var nextFrom = currentClientPos;
var nextTo = new Point((byte)(nextFrom.X + 2), nextFrom.Y);
currentClientPos = nextTo;
// Reset the alert debounce to allow consecutive warnings.
player.GameContext.FeaturePlugIns.GetPlugIn()!.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// The account should be banned because we sent rapid walks.
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Banned));
}
///
/// Tests that a high movement speed character walking at their maximum speed (which resolves
/// to step delay below the minimum expected delay floor of 50ms) does not trigger false positive bans.
///
[Test]
public async Task TestHighMovementSpeedDoesNotTriggerFalsePositivesAsync()
{
// Speed attribute is 100, which yields expected step delay: 4000.0 / 100.0 = 40ms.
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
player.Attributes![Stats.MovementSpeed] = 100;
player.Position = StartPoint;
// Perform rapid walks spaced according to the expected 40ms step delay.
// Even though 40ms is below the 50ms minimum limit floor, it should not trigger violations
// because of our dynamic flooring scaling fix.
for (int i = 0; i < 20; i++)
{
var nextFrom = new Point((byte)(StartPoint.X + i * 2), StartPoint.Y);
var nextTo = new Point((byte)(StartPoint.X + i * 2 + 2), StartPoint.Y);
player.Position = nextFrom;
player.GameContext.FeaturePlugIns.GetPlugIn()!.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
// Simulate walk packet processing spaced at 80ms interval (40ms expected per tile * 2 tiles).
await Task.Delay(80).ConfigureAwait(false);
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// The account should remain normal.
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(player), Is.EqualTo(0));
}
///
/// Tests that the speed check is bypassed when the plugin is disabled.
///
[Test]
public async Task TestBypassedWhenPluginDisabledAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
// Deactivate the plugin
player.GameContext.PlugInManager.DeactivatePlugIn();
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
// Rapid walks which would normally ban the player
for (int i = 0; i < 12; i++)
{
var nextFrom = new Point((byte)(StartPoint.X + i * 2), StartPoint.Y);
var nextTo = new Point((byte)(StartPoint.X + i * 2 + 2), StartPoint.Y);
player.Position = nextFrom;
player.GameContext.FeaturePlugIns.GetPlugIn()?.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// The account should remain normal because plugin is disabled
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()?.GetWarningCount(player) ?? 0, Is.EqualTo(0));
}
///
/// Tests that customized warning thresholds and ban rules are respected.
///
[Test]
public async Task TestCustomizedWarningThresholdAndNoBanAsync()
{
var player = await CreatePlayerWithSpeedAttributesAsync().ConfigureAwait(false);
var plugin = player.GameContext.FeaturePlugIns.GetPlugIn();
Assert.That(plugin, Is.Not.Null);
// Customize configuration: 5 warnings limit, no autoban, no disconnect
plugin!.Configuration!.MaxWarnings = 5;
plugin.Configuration.AutoBan = false;
plugin.Configuration.DisconnectOnViolation = false;
// Perform rapid walks to exceed the original 3 warnings limit, and the new 5 warnings limit.
// Needs 3 walks per warning, so 3 * 6 warnings = 18 walks to trigger warnings beyond limit.
for (int i = 0; i < 18; i++)
{
var nextFrom = new Point((byte)(StartPoint.X + i * 2), StartPoint.Y);
var nextTo = new Point((byte)(StartPoint.X + i * 2 + 2), StartPoint.Y);
player.Position = nextFrom;
player.GameContext.FeaturePlugIns.GetPlugIn()!.SetLastAlertTime(player, DateTime.MinValue);
WalkingStep[] steps = [new() { From = nextFrom, To = nextTo, Direction = Direction.East }];
await player.WalkToAsync(nextTo, steps).ConfigureAwait(false);
}
// The account should still be normal (no autoban) and not disconnected
Assert.That(player.Account?.State, Is.EqualTo(AccountState.Normal));
Assert.That(player.GameContext.FeaturePlugIns.GetPlugIn()!.GetWarningCount(player), Is.GreaterThan(5));
}
private static async ValueTask CreatePlayerWithSpeedAttributesAsync(List
- ? inventoryItems = null)
{
var gameConfig = new Mock();
gameConfig.SetupAllProperties();
gameConfig.Setup(c => c.Maps).Returns(new List());
gameConfig.Setup(c => c.Items).Returns(new List());
gameConfig.Setup(c => c.Skills).Returns(new List());
gameConfig.Setup(c => c.PlugInConfigurations).Returns(new List());
gameConfig.Setup(c => c.CharacterClasses).Returns(new List());
gameConfig.Setup(c => c.GlobalAttributeCombinations).Returns(new List());
gameConfig.Setup(c => c.GlobalBaseAttributeValues).Returns(new List
{
new(1, Stats.MoneyAmountRate),
});
var mapDefinition = new Mock();
mapDefinition.SetupAllProperties();
mapDefinition.Setup(m => m.DropItemGroups).Returns(new List());
mapDefinition.Setup(m => m.MonsterSpawns).Returns(new List());
mapDefinition.Object.TerrainData = new byte[ushort.MaxValue + 3];
gameConfig.Object.RecoveryInterval = int.MaxValue;
gameConfig.Object.Maps.Add(mapDefinition.Object);
var mapInitializer = new MapInitializer(gameConfig.Object, new NullLogger(), NullDropGenerator.Instance, null);
var gameContext = new GameContext(gameConfig.Object, new InMemoryPersistenceContextProvider(), mapInitializer, new NullLoggerFactory(), new PlugInManager(null, new NullLoggerFactory(), null, null), NullDropGenerator.Instance, new ConfigurationChangeMediator());
mapInitializer.PlugInManager = gameContext.PlugInManager;
mapInitializer.PathFinderPool = gameContext.PathFinderPool;
var characterMock = new Mock();
characterMock.SetupAllProperties();
characterMock.Setup(c => c.LearnedSkills).Returns(new List());
characterMock.Setup(c => c.Attributes).Returns(new List());
characterMock.Setup(c => c.DropItemGroups).Returns(new List());
var inventoryMock = new Mock();
inventoryMock.SetupAllProperties();
inventoryMock.Setup(i => i.Items).Returns(inventoryItems ?? new List
- ());
var character = characterMock.Object;
character.Inventory = inventoryMock.Object;
character.CurrentMap = gameContext.Configuration.Maps.FirstOrDefault(m => m.Number == 0);
var characterClassMock = new Mock();
characterClassMock.Setup(c => c.StatAttributes).Returns(
new List
{
new(Stats.Level, 1, false),
new(Stats.BaseStrength, 28, true),
new(Stats.BaseAgility, 20, true),
new(Stats.BaseVitality, 25, true),
new(Stats.BaseEnergy, 10, true),
new(Stats.CurrentHealth, 100, false),
new(Stats.CurrentMana, 100, false),
new(Stats.CurrentShield, 100, false),
new(Stats.Resets, 0, false),
new(Stats.AttackSpeed, 20, false), // Added Stats.AttackSpeed to StatAttributes
new(Stats.MovementSpeed, 0, false),
new(Stats.MovementSpeedUnderwater, 0, false),
new(Stats.MovementSpeedFactor, 1f, false)
});
characterClassMock.Setup(c => c.AttributeCombinations).Returns(new List
{
new(Stats.TotalStrength, 1, Stats.BaseStrength),
new(Stats.TotalAgility, 1, Stats.BaseAgility),
new(Stats.TotalVitality, 1, Stats.BaseVitality),
new(Stats.TotalEnergy, 1, Stats.BaseEnergy),
new(Stats.MaximumMana, 1, Stats.BaseEnergy),
new(Stats.MaximumHealth, 2, Stats.BaseVitality),
});
characterClassMock.Setup(c => c.BaseAttributeValues).Returns(new List
{
new(10, Stats.MaximumMana),
new(35, Stats.MaximumHealth),
});
character.CharacterClass = characterClassMock.Object;
foreach (var attributeDef in character.CharacterClass.StatAttributes)
{
character.Attributes.Add(new StatAttribute(attributeDef.Attribute!, attributeDef.BaseValue));
}
var account = new TestAccount { State = AccountState.Normal };
var player = new TestPlayer(gameContext) { Account = account };
var speedHackDetectPlugIn = new SpeedHackDetectPlugIn { Configuration = new SpeedHackDetectConfiguration() };
player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(speedHackDetectPlugIn);
player.GameContext.PlugInManager.RegisterPlugInAtPlugInPoint(speedHackDetectPlugIn);
player.GameContext.FeaturePlugIns.AddPlugIn(speedHackDetectPlugIn, true);
await player.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
await player.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false);
await player.PlayerState.TryAdvanceToAsync(PlayerState.CharacterSelection).ConfigureAwait(false);
await player.SetSelectedCharacterAsync(character).ConfigureAwait(false);
return player;
}
private class TestAccount : Account
{
public TestAccount()
{
this.Attributes = new List();
this.UnlockedCharacterClasses = new List();
this.Characters = new List();
}
}
private class TestPlayer : Player
{
public TestPlayer(IGameContext gameContext)
: base(gameContext)
{
}
protected override ICustomPlugInContainer CreateViewPlugInContainer()
{
return new MockViewPlugInContainer();
}
}
}