using ACE.Server.Network.Enum;
using ACE.Server.Network.Sequence;
using ACE.Server.Physics.Animation;
using PhysicsPosition = ACE.Server.Physics.Common.Position;
namespace Aeshnidae.MoveGuard;
///
/// The judgement. Called for every position the client asks the server to adopt.
///
/// What ACE does on its own, and why it is not enough. Player.UpdatePlayerPosition
/// has a speed check, but it fires only when one update crosses more than a whole
/// landblock (192 units) - inside that, any distance is taken at face value. It also
/// runs a physics transition from the old position to the new one, and when that
/// transition cannot get there (a wall) it logs at Debug level and forces the requested
/// position anyway. A blink plugin lives entirely inside those two gaps: it writes a
/// position into the client, the client reports it, and the server obliges.
///
/// What this does instead. The distance a client may move between two accepted
/// positions is bounded by that player's own top speed times the time elapsed, and
/// separately the same physics transition ACE runs is asked how far it actually got.
/// A move outside either bound is a strike; in Enforce mode it is also refused, and
/// the client is told to adopt the server's position - which is where everyone else
/// already sees you, where monsters hit you, and what every distance check uses.
///
internal static class Guard
{
///
/// Harmony prefix body. Returning true lets ACE's method run; returning false
/// skips it and hands back .
///
public static bool Judge(Player player, Position? newPosition, bool forceUpdate, ref bool result)
{
var s = Mod.Settings;
var mode = s.ModeValue;
if (mode == Mode.Off || newPosition is null)
return true;
// Server-initiated moves: the teleport landing itself (forceUpdate) and the
// packets the client keeps sending from the old spot while it loads.
if (forceUpdate || player.Teleporting)
return true;
var state = Tracker.For(player);
var now = DateTime.UtcNow;
// First position after login: nothing to measure from yet.
if (state.LastAccepted == default)
{
state.LastAccepted = now;
return true;
}
if ((now - player.LastTeleportTime).TotalSeconds < s.TeleportGraceSeconds)
return true;
var from = player.Location;
if (from is null)
return true;
// A move between landblocks where either end is indoors is ACE's to judge -
// its ValidateMovement refuses those outright, and dungeon landblock ids do
// not sit on the outdoor grid, so a distance between them means nothing.
if (from.Landblock != newPosition.Landblock &&
((from.Cell & 0xFFFF) >= 0x100 || (newPosition.Cell & 0xFFFF) >= 0x100))
return true;
var elapsed = Math.Max(0.0, (now - state.LastAccepted).TotalSeconds);
var distance = (double)from.Distance2D(newPosition);
var ceiling = Speed.CeilingFor(player);
var allowed = ceiling * s.SpeedTolerance * Math.Min(elapsed, s.MaxCatchUpSeconds) + s.SlackUnits;
Violation? violation = null;
if (distance > allowed)
{
violation = new("speed", distance, allowed, elapsed, ceiling, newPosition.ToLOCString());
}
else if (s.CheckGeometry && distance > 0.5)
{
var shortfall = Geometry.Shortfall(player, newPosition, distance);
if (shortfall > s.GeometryTolerance)
violation = new("geometry", shortfall, s.GeometryTolerance, elapsed, ceiling, newPosition.ToLOCString());
}
if (violation is null)
return true;
return Strike(player, state, violation, now, mode, ref result);
}
///
/// Records the strike, reports it, and - when enforcing - refuses the move.
///
private static bool Strike(Player player, PlayerState state, Violation v, DateTime now, Mode mode, ref bool result)
{
var s = Mod.Settings;
state.Last = v;
if (v.Kind == "speed")
{
state.SpeedViolations++;
Interlocked.Increment(ref Mod.SpeedViolations);
}
else
{
state.GeometryViolations++;
Interlocked.Increment(ref Mod.GeometryViolations);
}
var inWindow = state.AddStrike(now, s.StrikeWindowSeconds);
if (inWindow < state.StrikesAtLastAudit)
state.StrikesAtLastAudit = 0; // the window emptied out; start counting toward a fresh post
var exempt = s.IsExempt(player);
var enforce = mode == Mode.Enforce && !exempt && (v.Kind == "speed" || s.EnforceGeometry);
var outcome = enforce ? "rejected" : exempt ? "logged (staff)" : "logged";
// The log line is throttled per player; the audit channel is the signal. A
// systematic false positive - say a wrong run-speed reading - would otherwise
// write one line per moving player per second, and bury the real offenders.
if (inWindow <= s.LogPerWindow)
ModManager.Log($"[{Mod.Name}] {player.Name}: {Describe(v)} - {outcome}", ModManager.LogLevel.Warn);
else if (inWindow == s.LogPerWindow + 1)
ModManager.Log($"[{Mod.Name}] {player.Name}: further strikes this window not logged individually " +
$"(/moveguard check {player.Name})", ModManager.LogLevel.Warn);
if (s.AuditAfterStrikes > 0 && inWindow >= s.AuditAfterStrikes && inWindow - state.StrikesAtLastAudit >= s.AuditAfterStrikes)
{
state.StrikesAtLastAudit = inWindow;
PlayerManager.BroadcastToAuditChannel(null,
$"[MoveGuard] {player.Name} ({player.Account?.AccountName ?? "?"}): {inWindow} rejected-or-flagged " +
$"positions in the last {s.StrikeWindowSeconds}s, latest {Describe(v)} - {outcome}");
}
if (!enforce)
return true;
state.Rejections++;
Interlocked.Increment(ref Mod.Rejections);
var rejectedInWindow = state.AddRejection(now, s.StrikeWindowSeconds);
// Restart the clock. Without this a client that ignores the snap-back and keeps
// reporting the same far position is simply waited out: the allowance grows with
// elapsed time until it covers the blink, and the move is accepted with one
// strike to show for it. Measuring from the rejection instead means a position
// the server refused stays refused until the client comes back within reach.
// The retail client adopts the forced position within a tick, so an honest
// player's next report is from where the server put them and passes anyway.
state.LastAccepted = now;
// Rubber band. Location is left exactly where the server had it; bumping the
// force-position sequence is what makes the client adopt a position for its
// own character instead of ignoring it. This is the same pair of calls ACE's
// own z-hack response uses.
player.Sequences.GetNextSequence(SequenceType.ObjectForcePosition);
player.SendUpdatePosition();
if (s.TellPlayer)
player.SendMessage(s.PlayerMessage);
if (s.KickAfterStrikes > 0 && rejectedInWindow >= s.KickAfterStrikes && !state.Kicked)
{
state.Kicked = true;
Interlocked.Increment(ref Mod.Kicks);
PlayerManager.BroadcastToAuditChannel(null,
$"[MoveGuard] {player.Name} ({player.Account?.AccountName ?? "?"}) disconnected after {rejectedInWindow} rejected positions in {s.StrikeWindowSeconds}s");
player.Session?.Terminate(SessionTerminationReason.AccountBooted,
new GameMessageBootAccount(" for movement the server could not accept"));
}
result = false;
return false;
}
public static string Describe(Violation v) => v.Kind == "speed"
? $"moved {v.Distance:F1} units in {v.Elapsed:F2}s, allowed {v.Allowed:F1} (top speed {v.Ceiling:F1} u/s) to {v.Where}"
: $"physics reached {v.Distance:F1} units short of the requested position (tolerance {v.Allowed:F1}) at {v.Where}";
}
///
/// A player's real top speed, in units per second, from the same two inputs the
/// client uses: the run animation's distance per second, and the run-rate multiplier
/// ACE derives from the Run skill (capped at 4.5 from skill 800).
///
internal static class Speed
{
public static double CeilingFor(Player player)
{
var s = Mod.Settings;
double anim = 0;
try { anim = MotionTable.GetRunSpeed(player.MotionTableId); }
catch { /* fall through to the fallback */ }
if (anim <= 0)
anim = s.FallbackRunSpeed;
// Burden and exhaustion only ever LOWER the rate the client moves at, and the
// server's view of both can lag the client's by a tick - so they are left out,
// and this is an upper bound rather than the exact figure.
var runSkill = player.GetCreatureSkill(Skill.Run)?.Current ?? 0;
var rate = MovementSystem.GetRunRate(0f, (int)runSkill, 1.0f);
return anim * rate * (player.ObjScale ?? 1.0f);
}
}
///
/// Asks the physics engine the question ACE asks and then ignores: starting from where
/// the server has you, can you get to where the client says you are?
///
internal static class Geometry
{
///
/// How far short of the requested position the collision transition stops, in
/// units. 0 means it got there. Physics slides along obstacles rather than failing
/// outright, so a wall shows up as a large shortfall; a null transition (nowhere
/// valid at all) is reported as the whole distance.
///
public static double Shortfall(Player player, Position requested, double requestedDistance)
{
try
{
var phys = player.PhysicsObj;
if (phys?.CurCell is null)
return 0;
var target = new PhysicsPosition(requested);
var transit = phys.transition(phys.Position, target, false);
if (transit?.SpherePath?.CurPos is null)
return requestedDistance;
return transit.SpherePath.CurPos.Distance(target);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] geometry check failed for {player.Name}: {ex.Message}", ModManager.LogLevel.Warn);
return 0; // never strike on our own error
}
}
}