/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IncomingAccountPackets.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 . *
*************************************************************************/
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using Server.Accounting;
using Server.Engines.CharacterCreation;
using Server.Misc;
using Server.Mobiles;
namespace Server.Network;
public static class IncomingAccountPackets
{
// Initial capacity and the point at which issuing sweeps expired ids. Not a cap; the window
// grows rather than evicting a live id.
private const int _authIDWindowSize = 128;
private static int _authIdPurgeThreshold = _authIDWindowSize;
// The gap between PlayServerAck and the game login is seconds. Bounds how long a stolen id
// stays usable.
private static readonly TimeSpan _authIDLifetime = TimeSpan.FromMinutes(2.0);
private static readonly Dictionary _authIDWindow =
new(_authIDWindowSize);
internal struct AuthIDPersistence
{
public DateTime Age;
public readonly ClientVersion Version;
// GameLogin skips its password verify when both match, so the id is a bearer token and has
// to be bound to whatever earned it.
public readonly IAccount Account;
public readonly IPAddress Address;
public AuthIDPersistence(ClientVersion v, IAccount account, IPAddress address)
{
Age = Core.Now;
Version = v;
Account = account;
Address = Utility.Intern(address);
}
}
internal enum AuthIdResult
{
// No such id, or it was issued for a different account or address.
Rejected,
// Right account and address, too old to stand in for the verify. Idling on the server list
// is normal, so this falls back to the password check rather than becoming a lockout.
Expired,
// Issued to this account, from this address, recently. Stands in for the password verify.
Vouched
}
public static unsafe void Configure()
{
IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true);
IncomingPackets.Register(0x5D, &PlayCharacter, 73, outgameOnly: true);
IncomingPackets.Register(0x80, &AccountLogin, 62, outgameOnly: true);
IncomingPackets.Register(0x83, &DeleteCharacter, 39, outgameOnly: true);
IncomingPackets.Register(0x91, &GameLogin, 65, outgameOnly: true);
IncomingPackets.Register(0xA0, &PlayServer, 3, outgameOnly: true);
IncomingPackets.Register(0xBD, &ClientVersion);
IncomingPackets.Register(0xE1, &ClientType);
IncomingPackets.Register(0xEF, &LoginServerSeed, 21, outgameOnly: true);
IncomingPackets.Register(0xF8, &CreateCharacter, 106, outgameOnly: true);
}
public static void CreateCharacter(NetState state, SpanReader reader)
{
reader.Seek(9, SeekOrigin.Current);
/*
var unk1 = reader.ReadInt32();
var unk2 = reader.ReadInt32();
int unk3 = reader.ReadByte();
*/
var name = reader.ReadLatin1Safe(30);
reader.Seek(2, SeekOrigin.Current);
var flags = reader.ReadInt32();
reader.Seek(8, SeekOrigin.Current);
int prof = reader.ReadByte();
reader.Seek(15, SeekOrigin.Current);
var genderRace = reader.ReadByte();
// Strength, Dex, Intelligence
byte[] stats = [reader.ReadByte(), reader.ReadByte(), reader.ReadByte()];
var skills = new (SkillName, byte)[state.NewCharacterCreation ? 4 : 3];
skills[0] = ((SkillName)reader.ReadByte(), reader.ReadByte());
skills[1] = ((SkillName)reader.ReadByte(), reader.ReadByte());
skills[2] = ((SkillName)reader.ReadByte(), reader.ReadByte());
if (state.NewCharacterCreation)
{
skills[3] = ((SkillName)reader.ReadByte(), reader.ReadByte());
}
int hue = reader.ReadUInt16();
int hairVal = reader.ReadInt16();
int hairHue = reader.ReadInt16();
int hairValf = reader.ReadInt16();
int hairHuef = reader.ReadInt16();
reader.ReadByte();
int cityIndex = reader.ReadByte();
reader.Seek(8, SeekOrigin.Current);
/*
var charSlot = reader.ReadInt32();
var clientIP = reader.ReadInt32();
*/
int shirtHue = reader.ReadInt16();
int pantsHue = reader.ReadInt16();
/*
Pre-7.0.0.0:
0x00, 0x01 -> Human Male, Human Female
0x02, 0x03 -> Elf Male, Elf Female
Post-7.0.0.0:
0x00, 0x01
0x02, 0x03 -> Human Male, Human Female
0x04, 0x05 -> Elf Male, Elf Female
0x05, 0x06 -> Gargoyle Male, Gargoyle Female
*/
var female = genderRace % 2 != 0;
var raceID = state.StygianAbyss ? (byte)(genderRace < 4 ? 0 : genderRace / 2 - 1) : (byte)(genderRace / 2);
var race = Race.Races[raceID] ?? Race.DefaultRace;
var info = state.CityInfo;
var a = state.Account;
if (info == null || a == null || cityIndex >= info.Length)
{
state.Disconnect("Invalid city selected during character creation.");
return;
}
// Check if anyone is using this account
for (var i = 0; i < a.Length; ++i)
{
var check = a[i];
if (check != null && check.Map != Map.Internal)
{
state.LogInfo("Account in use");
state.SendPopupMessage(PMMessage.CharInWorld);
return;
}
}
state.Flags = (ClientFlags)flags;
var args = new CharacterCreatedEventArgs(
state,
a,
name,
female,
hue,
stats,
info[cityIndex],
skills,
shirtHue,
pantsHue,
hairVal,
hairHue,
hairValf,
hairHuef,
prof,
race
);
state.SendClientVersionRequest();
state.BlockAllPackets = true;
CharacterCreation.CharacterCreatedEvent(args);
var m = args.Mobile;
if (m != null)
{
state.Mobile = m;
m.NetState = state;
new LoginTimer(state, m).Start();
}
else
{
state.BlockAllPackets = false;
state.Disconnect("Character creation blocked.");
}
}
public static void DeleteCharacter(NetState state, SpanReader reader)
{
reader.Seek(30, SeekOrigin.Current);
var index = reader.ReadInt32();
AccountHandler.DeleteRequest(state, index);
}
public static void ClientVersion(NetState state, SpanReader reader)
{
var version = state.Version = new ClientVersion(reader.ReadAscii());
// Record RTT if this is a response to our probe
state.RecordRttMeasurement();
ClientVerification.ClientVersionReceived(state, version);
}
public static void ClientType(NetState state, SpanReader reader)
{
reader.ReadUInt16();
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);
}
public static void PlayCharacter(NetState state, SpanReader reader)
{
reader.Seek(36, SeekOrigin.Current); // 4 = 0xEDEDEDED, 30 = Name, 2 = unknown
var flags = reader.ReadInt32();
reader.Seek(24, SeekOrigin.Current);
var charSlot = reader.ReadInt32();
reader.Seek(4, SeekOrigin.Current); // var clientIP = reader.ReadInt32();
var a = state.Account;
if (a == null || charSlot < 0 || charSlot >= a.Length)
{
state.Disconnect("Invalid character slot selected.");
return;
}
var m = a[charSlot];
// Check if anyone is using this account
for (var i = 0; i < a.Length; ++i)
{
var check = a[i];
if (check != null && check.Map != Map.Internal && check != m)
{
state.LogInfo("Account in use");
state.SendPopupMessage(PMMessage.CharInWorld);
return;
}
}
if (m == null)
{
state.Disconnect("Empty character slot selected.");
return;
}
m.NetState?.Disconnect("Character selected for a player already logged in.");
state.SendClientVersionRequest();
state.BlockAllPackets = true;
state.Flags = (ClientFlags)flags;
state.Mobile = m;
m.NetState = state;
new LoginTimer(state, m).Start();
}
public static void DoLogin(this NetState state, Mobile m)
{
state.SendLoginConfirmation(m);
state.SendMapChange(m.Map);
state.SendMapPatches();
state.SendSeasonChange((byte)m.GetSeason(), true);
state.SendSupportedFeature();
state.ResetMovementState();
state.SendMobileUpdate(m);
state.SendMobileUpdate(m);
m.CheckLightLevels(true);
state.SendMobileUpdate(m);
state.SendMobileIncoming(m, m);
state.SendMobileStatus(m);
state.SendSetWarMode(m.Warmode);
m.SendEverything();
state.SendSupportedFeature();
state.SendMobileUpdate(m);
state.SendMobileStatus(m);
state.SendSetWarMode(m.Warmode);
state.SendMobileIncoming(m, m);
state.SendLoginComplete();
state.SendCurrentTime();
state.SendSeasonChange((byte)m.GetSeason(), true);
state.SendMapChange(m.Map);
state.SendPlayMusic(m.Region.Music);
if (m is PlayerMobile pm)
{
PlayerMobile.PlayerLoginEvent(pm);
}
}
private static int GenerateAuthID(this NetState state) =>
EnsureAuthId(state.AuthId, state.Account, state.Address, state.Version);
///
/// One id per connection, by construction. Choosing a server queues a disconnect that is not
/// drained until the next slice, so a client pipelining another select into the same buffer
/// arrives here again; handing back the id it already holds cannot orphan one.
///
internal static int EnsureAuthId(int existingAuthId, IAccount account, IPAddress address, ClientVersion version)
=> existingAuthId != 0 ? existingAuthId : RegisterAuthId(account, address, version);
internal static int RegisterAuthId(IAccount account, IPAddress address, ClientVersion version)
{
// Sweep the ids left behind by clients that picked a server and never arrived, but never
// evict a live one to make room -- the client holding it is on its way to redeem it. If all
// are live the window grows, which is a login rush, not a backlog. Each entry costs a
// successful password verify, so the size is self-limiting.
if (_authIDWindow.Count >= _authIdPurgeThreshold)
{
PurgeExpiredAuthIds();
_authIdPurgeThreshold = Math.Max(_authIDWindowSize, _authIDWindow.Count * 2);
}
int authID;
// The id stands in for a password verify, so it has to be unguessable. Zero is reserved:
// GameLogin reads state.AuthId == 0 as "no auth id was issued".
do
{
authID = RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue);
} while (authID == 0 || _authIDWindow.ContainsKey(authID));
_authIDWindow[authID] = new AuthIDPersistence(version, account, address);
return authID;
}
///
/// Spends an auth id, but only for the account and address it was issued to. An address
/// mismatch is rather than a fallback: network switching
/// mid-login is not supported.
///
internal static AuthIdResult ConsumeAuthId(int authId, string username, IPAddress address, out AuthIDPersistence entry)
{
if (!_authIDWindow.TryGetValue(authId, out entry))
{
return AuthIdResult.Rejected;
}
// Look, then take: removing before ownership is proven would let anyone landing on a live id
// burn it, leaving its owner to log in again. Address before username, so a remote guesser
// never learns whether a username matched.
if (!Utility.Intern(address).Equals(entry.Address)
|| entry.Account == null || !username.InsensitiveEquals(entry.Account.Username))
{
entry = default;
return AuthIdResult.Rejected;
}
// Theirs, so spend it. Expired counts as spent; it has done all it is ever going to do.
_authIDWindow.Remove(authId);
return Core.Now - entry.Age > _authIDLifetime ? AuthIdResult.Expired : AuthIdResult.Vouched;
}
private static void PurgeExpiredAuthIds()
{
var now = Core.Now;
foreach (var (key, entry) in _authIDWindow)
{
if (now - entry.Age > _authIDLifetime)
{
_authIDWindow.Remove(key);
}
}
}
internal static void ClearAuthIdWindow()
{
_authIDWindow.Clear();
_authIdPurgeThreshold = _authIDWindowSize;
}
internal static int AuthIdWindowCount => _authIDWindow.Count;
public static void GameLogin(NetState state, SpanReader reader)
{
if (state.SentFirstPacket)
{
state.Disconnect("Duplicate game login packet received.");
return;
}
state.SentFirstPacket = true;
var authId = reader.ReadInt32();
if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed)
{
state.LogInfo("Invalid client detected, disconnecting...");
state.Disconnect("Invalid auth id in game login packet.");
return;
}
var username = reader.ReadLatin1Safe(30);
var password = reader.ReadLatin1Safe(30);
var authResult = ConsumeAuthId(authId, username, state.Address, out var ap);
if (authResult == AuthIdResult.Rejected)
{
state.LogInfo("Invalid client detected, disconnecting...");
state.Disconnect("Unable to find auth id.");
return;
}
state.Version = ap.Version;
state.Seeded = true;
// Expired carries a usable entry; only the password verify skip is withheld.
var e = new GameServer.GameLoginEventArgs(
state,
username,
password,
authResult == AuthIdResult.Vouched
);
GameServer.GameServerLoginEvent(e);
if (e.Accepted)
{
state.CityInfo = e.CityInfo;
// Comment out these lines to turn off huffman compression
state.CompressionEnabled = true;
state.SendSupportedFeature();
state.SendCharacterList();
}
else
{
state.Disconnect("Login rejected by GameLogin packet handler.");
}
}
public static void PlayServer(NetState state, SpanReader reader)
{
// A server is picked once per connection. Picking again hands back an id this connection may
// already have spent on a game login, which the client could never redeem.
if (state.AuthId != 0)
{
state.Disconnect("Duplicate play server packet sent.");
return;
}
int index = reader.ReadInt16();
var info = state.ServerInfo;
var a = state.Account;
if (info == null || a == null || index < 0 || index >= info.Length)
{
state.Disconnect("Invalid server selected.");
}
else
{
var si = info[index];
state.AuthId = state.GenerateAuthID();
state.SentFirstPacket = false;
state.SendPlayServerAck(si, state.AuthId);
}
}
public static void LoginServerSeed(NetState state, SpanReader reader)
{
// Seeding happens once per connection. A second one restarts a handshake this connection
// already completed, which no real client does.
if (state.Seeded)
{
state.Disconnect("Duplicate login server seed packet sent.");
return;
}
state.Seed = reader.ReadInt32();
state.Seeded = true;
if (state.Seed == 0)
{
state.LogInfo("Invalid client detected, disconnecting");
state.Disconnect("Invalid client detected");
return;
}
var clientMaj = reader.ReadInt32();
var clientMin = reader.ReadInt32();
var clientRev = reader.ReadInt32();
var clientPat = reader.ReadInt32();
state.Version = new ClientVersion(clientMaj, clientMin, clientRev, clientPat);
}
public static void AccountLogin(NetState state, SpanReader reader)
{
if (state.SentFirstPacket)
{
state.Disconnect("Duplicate account login packet sent.");
return;
}
state.SentFirstPacket = true;
var username = reader.ReadLatin1Safe(30);
var password = reader.ReadLatin1Safe(30);
var accountLoginEventArgs = new AccountLoginEventArgs(state, username, password);
EventSink.InvokeAccountLogin(accountLoginEventArgs);
// The password check moved off the loop; whoever took it replies when the verdict lands.
if (accountLoginEventArgs.Deferred)
{
return;
}
CompleteAccountLogin(state, accountLoginEventArgs.Accepted, accountLoginEventArgs.RejectReason);
}
///
/// Replies to an account login. Split out so a verdict produced off the loop reaches the client
/// through exactly the same path as one produced inline.
///
internal static void CompleteAccountLogin(NetState state, bool accepted, ALRReason rejectReason)
{
if (accepted)
{
var serverListEventArgs = new GatewayServer.ServerListEventArgs(state, state.Account);
GatewayServer.ServerListEvent(serverListEventArgs);
if (serverListEventArgs.Rejected)
{
state.Account = null;
AccountLogin_ReplyRej(state, ALRReason.BadComm);
}
else
{
state.ServerInfo = serverListEventArgs.Servers.ToArray();
state.SendAccountLoginAck();
}
}
else
{
state.Account = null;
AccountLogin_ReplyRej(state, rejectReason);
}
}
private static void AccountLogin_ReplyRej(this NetState state, ALRReason reason)
{
state.SendAccountLoginRejected(reason);
state.Disconnect($"Account login rejected due to {reason}");
}
private class LoginTimer : Timer
{
private readonly Mobile _mobile;
private readonly NetState _state;
public LoginTimer(NetState state, Mobile m) : base(TimeSpan.FromMilliseconds(64), TimeSpan.FromMilliseconds(64))
{
_state = state;
_mobile = m;
}
protected override void OnTick()
{
if (_state != null)
{
if (_state.Account == null)
{
_state.Disconnect("Account was deleted during the login process.");
}
else if (_mobile == null)
{
_state.Disconnect("Player was deleted during the login process.");
}
else if (_state.Version != null)
{
_state.BlockAllPackets = false;
DoLogin(_state, _mobile);
}
else // Waiting to receive the client version before we continue the login process
{
return;
}
}
Stop();
}
}
}