mirror of
https://github.com/modernuo/ModernUO
synced 2026-08-11 22:23:06 -04:00
## Why The shard owner, on a Starlink CGNAT address, was blocked by the imported reputation blocklist. The cause was not CrowdSec. The address was a literal line in `ip-blocklist.txt`, so `BlocklistFilter` denied it at accept and then promoted it — and clearing the CrowdSec decision could not fix it either, because the file entry re-reports within `promoteSuppression` of every reconnect attempt. This is structural, not a one-off. Reputation feeds list shared consumer address space constantly: on CGNAT one public address fronts many subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else behind it is blocked with them. Where leases rotate, a listing says little about whoever holds the address now. Around 1,000 Starlink addresses sit in the current list. So exemptions go where they cost nothing, and escalation is driven by what a connection actually does. ## Generator — `tools/Export-IpBlocklist.ps1` `-AllowlistFile` takes multiple paths, subtracted from the merged set before the output is written. Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set: - `ip-allowlist.txt` — operator exemptions, created once and **never rewritten** - `ip-allowlist-<name>.txt` — a carve-out you built, regenerable and copyable between shards **Subtraction is range-correct.** An allowlisted address inside a blocked CIDR splits that CIDR around the hole rather than being silently ignored. This also fixes `-ExcludeAnonymizers`, which parsed CIDR entries into `$anonCidr` and then only ever subtracted singles. **No carve-out ships.** A carve-out names a real network, and which ones a shard should exempt depends on where its players actually are — so publishing one would make that policy call for every shard and put a specific provider's address space in the repo. The script builds them on request instead: ```powershell .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 ``` Carve-outs are **discovered, not configured**: every `ip-allowlist*.txt` beside the output is subtracted, by the generator and by the shard, so a file an admin adds needs no config edit and no code change. Each carries an `asn=` marker in its header, which is how `-RefreshCarveouts` rebuilds it without the script keeping a list of anyone's networks; a hand-written allowlist has no marker and is never rewritten. Prefixes come from **announcements, not ownership records**, because registry data disagrees with what is actually routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and `206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink. Editing an allowlist bypasses `-MinInterval`, so a just-added exemption isn't indistinguishable from the allowlist not working. A Starlink carve-out, if you build one, costs **~4,300 IPs + ~144 CIDRs of 4.2M (0.10%)**. ## Allowlists **`FileAllowlist`** reads the same files the generator subtracts, so an operator entry means "leave this address alone" for real. Subtraction alone only covers being *blocked*; behavioural detections never consult the blocklist, so without this a carve-out was quietly routed around — one scanner behind a shared address was enough to get everyone behind it contributed and firewalled, with nothing in the shard's own config explaining why. Reading the files also means an entry applies on the next reload rather than the next regeneration, which is what matters when someone is complaining now. **`LoginAllowlist`** is earned by authenticating, with a 90-day TTL because an address that logged in years ago is a stranger. Its own store rather than `Account.LoginIPs`, which has no timestamps and cannot be backfilled. An entry is evidence rather than a licence: 10 suppressed contributions in an hour revokes it, and a fresh login forgives the tally. Both are consulted **only after the blocklist has already matched**, so a normal accept pays nothing for them and the accept gate stays allowlist-free. `BanExemptions` combines them behind `BanChannel.IsExempt` and suppresses escalation only — every local defence still applies. Two limits, both deliberate and documented in the class: `LoginAllowlist` **cannot bootstrap** (an entry is only earned by getting in, so it never repairs an existing false positive), and it is weakest on rotating CGNAT. That is why `FileAllowlist` is the fix for those, and why it is manual. ## Behavioural detection | Reason | Trigger | |---|---| | `silent-connect` | Reaped after 5s having sent **zero bytes** | | `invalid-seed` | Opened with a zero seed | | `foreign-protocol` | Positively identified as HTTP, TLS or SSH | **`ForeignProtocol` inverts the test.** Asking "is this a good UO client?" cannot work: `LoginEncryption.ClientDecrypt` is a byte-for-byte stream XOR, so a legitimate client with encryption enabled when the shard expects none sends a structurally perfect connection whose payload is noise. "Speaks HTTP" is safe where "unreadable" is not — however misconfigured a UO client is, it never sends `GET / HTTP/1.1`. Nothing assumes arrival framing. TCP has no message boundaries, so a rule of the form "these bytes must arrive together" is broken by construction and drops real players on poor links. A prefix match with too few bytes to confirm waits for more. A four-byte seed can legitimately spell `GET ` (the address 71.69.84.32) or `0x16 0x03 0x0?` (22.3.x.x), so confirmation requires the request line to continue in printable ASCII or an actual ClientHello inside a plausible record — a real client's fifth byte is a packet id (`0x80`, `0x91`, `0xEF`), none of them printable, so those collisions fall through. Everything is keyed on **bytes-received rather than elapsed time**. A connection that sent something and ran out of time is far more likely a slow link than an attack, and banning those produces the worst failure mode available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being firewalled off. ## `AutoDenylist` A short-lived local hold (15m) on behavioural detections, as `IConnectionFilter` + `IBanReporter` over one store so the engine detection sites never reach into content. This closes the gap where a flood pays for a socket, buffer and `NetState` slot per connection while waiting for the OS bouncer — the verdicts that matter most are reachable only *after* reading bytes — and it is the entire defence on a shard running no bouncer, which is the default config. Not persisted: a holding pen that survives restarts is a ban without a ban's review. Cost: one dictionary lookup on a usually-empty dict per accept. ## `BanReasons` Centralises the reason slugs. `IsBehavioral` is an **opt-in** set, not "everything except manual", so a future reason escalates normally instead of silently inheriting an exemption or entering a local denylist. This caught a real bug during review: the first cut of the exemption swallowed `manual` admin bans (`Commands.cs`, three sites in `AdminGump`) for any allowlisted address. ## Fixes found in review - **`BanConfiguration.Settings` was null until `Configure()` ran**, while the reap path dereferences it every `Slice()`. A harness driving `NetState.Slice()` directly hit an NRE that presented as flaky because it depended on whether an earlier test had already called `Configure()` — which is why it failed on some CI platforms and not others. Now starts at the record's defaults, with idempotency tracked by a flag; this also removes the same latent NRE from the pre-existing rate-limit path. - **`-AllowlistFile` was typed `[string]`** while documented and used as a list, so passing two paths would have collapsed them into one string. ## Layout and docs Content network code moves out of `Misc/` into `UOContent/Network/`, one concern per folder — `AutoDenylist/`, `Blocklist/`, `CrowdSec/`, `Firewall/`, `LoginAllowlist/`, `Packets/`. **Namespaces are untouched**, so these are pure file moves (git tracks all 16 as renames). `dev-docs/ip-bans-and-allowlists.md` documents the subsystem, leading with the operator process for unblocking a player — including the three things that look sufficient and are not: deleting the CrowdSec decision alone, editing `ip-blocklist.txt` by hand, and `cscli allowlists` alone. `.gitignore` covers the new config files. ## Testing Build clean. **Server.Tests 810 passed**, **UOContent.Tests 637 passed**, zero warnings. This branch adds 38 tests; the rest of the delta is main's, since this is rebased on current `main`. New coverage: TTL boundary and renewal, private-address exclusion, manual-ban-never-exempt, unopted-reason-never-exempt, strike revocation, quiet-window reset, login forgiveness, file-allowlist CIDR coverage, file-allowlist not spending the earned list's strikes, denylist expiry-on-read, cap enforcement, lapsed-entry reclaim, HTTP/TLS/SSH identification, seed-collision fall-through, and encrypted-login-is-not-foreign. Generator verified end-to-end against live feeds: a clean run ships no carve-out, `-AddCarveout starlink -Asn 14593` fetches and collapses 213 prefixes to 115 ranges in 0.1s over 4.2M entries, `-RefreshCarveouts` rediscovers it by its `asn=` marker, a hand-written allowlist is left untouched, and deleting a carve-out drops it rather than having it rewritten. CIDR splitting verified exhaustively: a single-IP hole in a /24 leaves exactly 255 of 256 addresses blocked. ## Operator note Existing installs are unaffected until the generator next runs, which creates `ip-allowlist.txt` and nothing else. To unblock someone: add the address to that file and delete any live CrowdSec decision — the existing ban outlives the config change. The shard picks the entry up on its next reload, so re-running the generator is optional. A shard whose players are on CGNAT (satellite, mobile, or an ISP short on IPv4) will likely also want `-AddCarveout`; see `dev-docs/ip-bans-and-allowlists.md`. ## Also included: a latent CI failure this PR surfaced `fix(tests): serialize test classes that rent through STArrayPool` touches a property-list test file that has nothing to do with this feature. It is here because it was failing macOS CI, and it is trivially cherry-pickable out if you would rather it went to `main` on its own — **which may be the better call, since it is failing `main` today.** CI has since gone green with it applied. `STArrayPool` is single-threaded by design and its bucket cache is a plain `static`, not `[ThreadStatic]`, with a check-then-act initialize in `Return()`: ```csharp var cacheBuckets = _cacheBuckets ?? InitializeBuckets(); ``` Two threads both see null, both initialize, and the loser trips `Debug.Assert(_cacheBuckets is null)`. Anything renting from it has to stay off parallel test threads — which is what the `DisableParallelization` collections are for. - `ObjectPropertyListReentrancyTests` and `ObjectPropertyListNestedBuildTests` (added in #2555) build property lists, which rent the interpolation buffer, but were not in the sequential collection — unlike `PropertyListInvalidationDuringBuildTests` in the same file. This is a **latent failure already on `main`**; it is timing-dependent, so it shows on some platforms and not others. - `AutoDenylistTests` (added here) has the same exposure: its cap tests reach `AutoDenylist.Sweep`, which rents a `PooledRefList` without `mt`. The blocklist tests need no marking because `BlocklistSnapshot.Build` asks for the `mt` pool explicitly. No production change — `STArrayPool` is the right pool on the game loop, where both `Sweep` and the property list actually run. ## Deliberately not included Waiting for a fragmented four-byte seed at `AwaitingSeed`. It looked like a bug but the disconnect is a deliberate defence: only pre-0xEF clients reach it (0xEF goes through `HandlePacket`, which already waits for its 21 bytes), and waiting converts an instant drop into a full 5s slot hold for a client sending one or two bytes, or a loris dribbling a byte every few seconds. Against a fixed 4096-entry `MaxConnections` table that trades capacity that matters for a fragmentation case a reconnect already fixes.
1189 lines
40 KiB
C#
Executable file
1189 lines
40 KiB
C#
Executable file
/*************************************************************************
|
|
* ModernUO *
|
|
* Copyright 2019-2026 - ModernUO Development Team *
|
|
* Email: hi@modernuo.com *
|
|
* File: NetState.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 Server.Accounting;
|
|
using Server.Collections;
|
|
using Server.HuePickers;
|
|
using Server.Items;
|
|
using Server.Logging;
|
|
using Server.Menus;
|
|
using System;
|
|
using System.Buffers;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Network;
|
|
using System.Runtime.CompilerServices;
|
|
|
|
namespace Server.Network;
|
|
|
|
public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetState>, IDisposable
|
|
{
|
|
private static readonly ILogger logger = LogFactory.GetLogger(typeof(NetState));
|
|
|
|
private const int HuePickerCap = 512;
|
|
private const int MenuCap = 512;
|
|
private const int PacketPerSecondThreshold = 3000;
|
|
|
|
private static readonly Queue<NetState> _flushPending = new(2048);
|
|
private static readonly Queue<NetState> _pendingDisconnects = new(256); // Processed AFTER flush
|
|
private static readonly Queue<NetState> _throttled = new(256);
|
|
private static readonly Queue<NetState> _throttledPending = new(256);
|
|
|
|
private static readonly Queue<NetState> _connectingQueue = new(2048);
|
|
private static readonly HashSet<NetState> _instances = new(2048);
|
|
public static IReadOnlySet<NetState> Instances => _instances;
|
|
|
|
private readonly string _toString;
|
|
private ClientVersion _version;
|
|
private bool _running = true;
|
|
private IClientEncryption _encryption;
|
|
private bool _flushQueued;
|
|
private bool _disconnectQueued; // Queued for disconnect processing (after flush)
|
|
private long[] _packetThrottles;
|
|
private long[] _packetCounts;
|
|
private string _disconnectReason = string.Empty;
|
|
|
|
internal ParserState _parserState = ParserState.AwaitingNextPacket;
|
|
internal ProtocolState _protocolState = ProtocolState.AwaitingSeed;
|
|
private bool _packetLogging;
|
|
|
|
// Whether ANY inbound bytes have arrived: what separates a slow client from a socket held open on
|
|
// purpose. See BanSettings.ReportBadConnects.
|
|
internal bool _receivedData;
|
|
|
|
// Managed socket with buffers (handles lifecycle automatically)
|
|
internal RingSocket _socket;
|
|
|
|
private IAccount _account;
|
|
|
|
internal enum ParserState
|
|
{
|
|
AwaitingNextPacket,
|
|
AwaitingPartialPacket,
|
|
ProcessingPacket,
|
|
Throttled,
|
|
Error
|
|
}
|
|
|
|
internal enum ProtocolState
|
|
{
|
|
AwaitingSeed, // Based on the way the seed arrives, we know if this is a login server or a game server connection
|
|
|
|
LoginServer_AwaitingLogin,
|
|
LoginServer_AwaitingServerSelect,
|
|
LoginServer_ServerSelectAck,
|
|
|
|
GameServer_AwaitingGameServerLogin,
|
|
GameServer_LoggedIn,
|
|
|
|
Error
|
|
}
|
|
|
|
private static string _packetLoggingPath;
|
|
|
|
public static void Configure()
|
|
{
|
|
_packetLoggingPath = ServerConfiguration.GetSetting("netstate.packetLoggingPath", Path.Combine(Core.BaseDirectory, "Packets"));
|
|
|
|
// Initialize IORingGroup and buffer pools
|
|
ConfigureNetwork();
|
|
}
|
|
|
|
// Internal constructor for accepted sockets
|
|
private NetState(RingSocket socket, IPAddress address)
|
|
{
|
|
_socket = socket;
|
|
Address = address;
|
|
|
|
Seeded = false;
|
|
HuePickers = [];
|
|
Menus = [];
|
|
Trades = [];
|
|
NextActivityCheck = Core.TickCount + 30000;
|
|
ConnectedOn = Core.Now;
|
|
_toString = address?.ToString() ?? "(error)";
|
|
|
|
_instances.Add(this);
|
|
_connectingQueue.Enqueue(this);
|
|
|
|
LogInfo($"Connected. [{_instances.Count} Online]");
|
|
}
|
|
|
|
// Sectors
|
|
public NetState Next { get; set; }
|
|
public NetState Previous { get; set; }
|
|
public bool OnLinkList { get; set; }
|
|
|
|
public long NextActivityCheck { get; private set; }
|
|
|
|
// Only use this for debugging. This will make your server very slow!
|
|
public bool PacketLogging
|
|
{
|
|
get => _packetLogging;
|
|
set
|
|
{
|
|
_packetLogging = value;
|
|
|
|
if (_packetLogging)
|
|
{
|
|
StartPacketLog();
|
|
}
|
|
}
|
|
}
|
|
|
|
public int AuthId { get; set; }
|
|
|
|
public int Seed { get; set; }
|
|
|
|
public DateTime ConnectedOn { get; }
|
|
|
|
public TimeSpan ConnectedFor => Core.Now - ConnectedOn;
|
|
|
|
public IPAddress Address { get; }
|
|
|
|
public IClientEncryption Encryption
|
|
{
|
|
get => _encryption;
|
|
set => _encryption = value;
|
|
}
|
|
|
|
public int CurrentPacket { get; internal set; }
|
|
|
|
public bool SentFirstPacket { get; set; }
|
|
|
|
public bool BlockAllPackets { get; set; }
|
|
|
|
public List<SecureTrade> Trades { get; }
|
|
|
|
public bool Seeded { get; set; }
|
|
|
|
public bool Running => _running;
|
|
|
|
/// <summary>
|
|
/// Gets whether the socket is connected.
|
|
/// </summary>
|
|
public bool IsConnected => _socket != null;
|
|
|
|
/// <summary>
|
|
/// Gets the socket handle.
|
|
/// </summary>
|
|
public nint SocketHandle => _socket?.Handle ?? 0;
|
|
|
|
/// <summary>
|
|
/// Gets the local endpoint (address/port) the client connected to.
|
|
/// </summary>
|
|
public IPEndPoint LocalEndPoint => _socket != null ? SocketHelper.GetLocalEndPoint(_socket.Handle) : null;
|
|
|
|
/// <summary>
|
|
/// Gets the send buffer for this connection.
|
|
/// </summary>
|
|
internal IORingBuffer SendBuffer => _socket?.SendBuffer;
|
|
|
|
public bool CompressionEnabled { get; set; }
|
|
|
|
public int Sequence { get; set; }
|
|
|
|
public List<HuePicker> HuePickers { get; private set; }
|
|
|
|
public List<IMenu> Menus { get; private set; }
|
|
|
|
public CityInfo[] CityInfo { get; set; }
|
|
|
|
public Mobile Mobile { get; set; }
|
|
|
|
public ServerInfo[] ServerInfo { get; set; }
|
|
|
|
public IAccount Account
|
|
{
|
|
get => _account;
|
|
set => _account = value;
|
|
}
|
|
|
|
public string Assistant { get; set; }
|
|
|
|
public int CompareTo(NetState other) => string.CompareOrdinal(_toString, other?._toString);
|
|
|
|
private void SetPacketTime(int packetID)
|
|
{
|
|
if (packetID is >= 0 and < 0x100)
|
|
{
|
|
_packetThrottles ??= new long[0x100];
|
|
_packetThrottles[packetID] = Core.TickCount;
|
|
}
|
|
}
|
|
|
|
public long GetPacketTime(int packetID) =>
|
|
packetID is >= 0 and < 0x100 && _packetThrottles != null ? _packetThrottles[packetID] : 0;
|
|
|
|
private void UpdatePacketCount(int packetID)
|
|
{
|
|
if (packetID is >= 0 and < 0x100)
|
|
{
|
|
_packetCounts ??= new long[0x100];
|
|
_packetCounts[packetID]++;
|
|
}
|
|
}
|
|
|
|
public int CheckPacketCounts()
|
|
{
|
|
if (_packetCounts == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
for (var i = 0; i < _packetCounts.Length; i++)
|
|
{
|
|
var count = _packetCounts[i];
|
|
_packetCounts[i] = 0;
|
|
|
|
if (count > PacketPerSecondThreshold)
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
public void ValidateAllTrades()
|
|
{
|
|
for (var i = Trades.Count - 1; i >= 0; --i)
|
|
{
|
|
if (i >= Trades.Count)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var trade = Trades[i];
|
|
|
|
if (trade.From.Mobile.Deleted || trade.To.Mobile.Deleted || !trade.From.Mobile.Alive ||
|
|
!trade.To.Mobile.Alive || !trade.From.Mobile.InRange(trade.To.Mobile, 2) ||
|
|
trade.From.Mobile.Map != trade.To.Mobile.Map)
|
|
{
|
|
trade.Cancel();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void CancelAllTrades()
|
|
{
|
|
for (var i = Trades.Count - 1; i >= 0; --i)
|
|
{
|
|
if (i < Trades.Count)
|
|
{
|
|
Trades[i].Cancel();
|
|
}
|
|
}
|
|
}
|
|
|
|
public void RemoveTrade(SecureTrade trade)
|
|
{
|
|
Trades.Remove(trade);
|
|
}
|
|
|
|
public SecureTrade FindTrade(Mobile m)
|
|
{
|
|
for (var i = 0; i < Trades.Count; ++i)
|
|
{
|
|
var trade = Trades[i];
|
|
|
|
if (trade.From.Mobile == m || trade.To.Mobile == m)
|
|
{
|
|
return trade;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public SecureTradeContainer FindTradeContainer(Mobile m)
|
|
{
|
|
for (var i = 0; i < Trades.Count; ++i)
|
|
{
|
|
var trade = Trades[i];
|
|
|
|
var from = trade.From;
|
|
var to = trade.To;
|
|
|
|
if (from.Mobile == Mobile && to.Mobile == m)
|
|
{
|
|
return from.Container;
|
|
}
|
|
|
|
if (from.Mobile == m && to.Mobile == Mobile)
|
|
{
|
|
return to.Container;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public SecureTradeContainer AddTrade(NetState state)
|
|
{
|
|
var newTrade = new SecureTrade(Mobile, state.Mobile);
|
|
|
|
Trades.Add(newTrade);
|
|
state.Trades.Add(newTrade);
|
|
|
|
return newTrade.From.Container;
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
public void LogInfo(string text)
|
|
{
|
|
logger.Information("Client: {NetState}: {Message}", this, text);
|
|
}
|
|
|
|
public void AddMenu(IMenu menu)
|
|
{
|
|
Menus ??= [];
|
|
|
|
if (Menus.Count < MenuCap)
|
|
{
|
|
Menus.Add(menu);
|
|
}
|
|
else
|
|
{
|
|
LogInfo("Exceeded menu cap, disconnecting...");
|
|
Disconnect("Exceeded menu cap.");
|
|
}
|
|
}
|
|
|
|
public void RemoveMenu(IMenu menu)
|
|
{
|
|
Menus?.Remove(menu);
|
|
}
|
|
|
|
public void RemoveMenu(int index)
|
|
{
|
|
Menus?.RemoveAt(index);
|
|
}
|
|
|
|
public void ClearMenus()
|
|
{
|
|
Menus?.Clear();
|
|
}
|
|
|
|
public void AddHuePicker(HuePicker huePicker)
|
|
{
|
|
HuePickers ??= [];
|
|
|
|
if (HuePickers.Count < HuePickerCap)
|
|
{
|
|
HuePickers.Add(huePicker);
|
|
}
|
|
else
|
|
{
|
|
LogInfo("Exceeded hue picker cap, disconnecting...");
|
|
Disconnect("Exceeded hue picker cap.");
|
|
}
|
|
}
|
|
|
|
public void RemoveHuePicker(HuePicker huePicker)
|
|
{
|
|
HuePickers?.Remove(huePicker);
|
|
}
|
|
|
|
public void RemoveHuePicker(int index)
|
|
{
|
|
HuePickers?.RemoveAt(index);
|
|
}
|
|
|
|
public void ClearHuePickers()
|
|
{
|
|
HuePickers?.Clear();
|
|
}
|
|
|
|
public void LaunchBrowser(ReadOnlySpan<char> url)
|
|
{
|
|
this.SendMessageLocalized(Serial.MinusOne, -1, MessageType.Label, 0x35, 3, 501231);
|
|
this.SendLaunchBrowser(url);
|
|
}
|
|
|
|
public override string ToString() => _toString;
|
|
|
|
public bool GetSendBuffer(out Span<byte> buffer)
|
|
{
|
|
#if THREADGUARD
|
|
if (Thread.CurrentThread != Core.Thread)
|
|
{
|
|
Utility.PushColor(ConsoleColor.Red);
|
|
Console.WriteLine("Attempting to get pipe buffer from wrong thread!");
|
|
Console.WriteLine(new StackTrace());
|
|
Utility.PopColor();
|
|
|
|
buffer = Array.Empty<byte>();
|
|
return false;
|
|
}
|
|
#endif
|
|
if (!_running || _socket == null)
|
|
{
|
|
buffer = Span<byte>.Empty;
|
|
return false;
|
|
}
|
|
|
|
buffer = _socket.SendBuffer.GetWriteSpan();
|
|
return buffer.Length > 0;
|
|
}
|
|
|
|
public void Send(ReadOnlySpan<byte> span)
|
|
{
|
|
if (span == ReadOnlySpan<byte>.Empty || this.CannotSendPackets())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var length = span.Length;
|
|
if (length <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Never drop silently: the client would stay connected while missing game state.
|
|
if (!GetSendBuffer(out var buffer))
|
|
{
|
|
SendBufferExhausted(length, 0);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Apply encoding first (e.g., compression from UOContent)
|
|
if (CompressionEnabled)
|
|
{
|
|
length = NetworkCompression.Compress(span, buffer);
|
|
|
|
// 0 means nothing was written, whether it did not fit or the input was too large.
|
|
if (length <= 0)
|
|
{
|
|
SendBufferExhausted(span.Length, buffer.Length);
|
|
return;
|
|
}
|
|
}
|
|
else if (span.Length > buffer.Length)
|
|
{
|
|
SendBufferExhausted(span.Length, buffer.Length);
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
span.CopyTo(buffer);
|
|
}
|
|
|
|
// Then encrypt (if encryption is enabled)
|
|
_encryption?.ServerEncrypt(buffer[..length]);
|
|
|
|
if (PacketLogging)
|
|
{
|
|
LogPacket(span, false);
|
|
}
|
|
|
|
_socket.SendBuffer.CommitWrite(length);
|
|
|
|
if (!_flushQueued)
|
|
{
|
|
_flushPending.Enqueue(this);
|
|
_flushQueued = true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TraceException(ex);
|
|
Disconnect("Exception while sending.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles a packet that cannot be placed in the send buffer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// High unacked means a slow client holding the buffer; needed approaching capacity means the
|
|
/// buffer is too small for this shard and network.sendBufferSize should be raised.
|
|
/// </remarks>
|
|
private void SendBufferExhausted(int needed, int writable)
|
|
{
|
|
var sendBuffer = _socket?.SendBuffer;
|
|
var unacked = sendBuffer?.InFlightBytes ?? 0;
|
|
var capacity = sendBuffer?.PhysicalSize ?? 0;
|
|
|
|
logger.Warning(
|
|
"{NetState}: send buffer exhausted - needed {Needed} bytes, {Writable} writable, {Unacked} awaiting acknowledgement, {Capacity} capacity. Raise network.sendBufferSize (power of two) if this recurs on healthy connections.",
|
|
this,
|
|
needed,
|
|
writable,
|
|
unacked,
|
|
capacity
|
|
);
|
|
|
|
Disconnect($"Send buffer exhausted (needed {needed}, writable {writable}, unacked {unacked}, capacity {capacity})");
|
|
}
|
|
|
|
private void StartPacketLog()
|
|
{
|
|
try
|
|
{
|
|
var logDir = Path.Combine(_packetLoggingPath, _toString);
|
|
PathUtility.EnsureDirectory(logDir);
|
|
var logPath = Path.Combine(logDir, "packets.log");
|
|
using var op = new StreamWriter(logPath, true);
|
|
|
|
op.WriteLine(">>>>>>>>>> Logging started {0:yyyy/MM/dd HH:mm::ss} <<<<<<<<<<", Core.Now);
|
|
op.WriteLine();
|
|
op.WriteLine();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine(e);
|
|
}
|
|
}
|
|
|
|
private void LogPacket(ReadOnlySpan<byte> buffer, bool incoming)
|
|
{
|
|
try
|
|
{
|
|
var logDir = Path.Combine(_packetLoggingPath, _toString);
|
|
PathUtility.EnsureDirectory(logDir);
|
|
var logPath = Path.Combine(logDir, "packets.log");
|
|
|
|
const string incomingStr = "Client -> Server";
|
|
const string outgoingStr = "Server -> Client";
|
|
|
|
using var sw = new StreamWriter(logPath, true);
|
|
sw.WriteLine($"{Core.Now:HH:mm:ss.ffff}: {(incoming ? incomingStr : outgoingStr)} 0x{buffer[0]:X2} (Length: {buffer.Length})");
|
|
sw.FormatBuffer(buffer);
|
|
sw.WriteLine();
|
|
sw.WriteLine();
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
private void DecryptRecvBuffer(int bytesReceived)
|
|
{
|
|
if (_socket == null || _encryption == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Get the portion of the buffer that was just written (the new data)
|
|
var readSpan = _socket.RecvBuffer.GetReadSpan();
|
|
var newDataStart = Math.Max(0, readSpan.Length - bytesReceived);
|
|
|
|
_encryption?.ClientDecrypt(readSpan.Slice(newDataStart, bytesReceived));
|
|
}
|
|
|
|
public void HandleReceive(bool throttled = false)
|
|
{
|
|
if (!_running || _socket == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Data already in recv buffer from recv completion - no need to call ReceiveData
|
|
try
|
|
{
|
|
// Process as many packets as we can synchronously
|
|
while (_running && _parserState != ParserState.Error && _protocolState != ProtocolState.Error)
|
|
{
|
|
var buffer = _socket.RecvBuffer.GetReadSpan();
|
|
var length = buffer.Length;
|
|
|
|
if (length <= 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var packetReader = new SpanReader(buffer);
|
|
var packetId = packetReader.ReadByte();
|
|
var packetLength = length;
|
|
|
|
// These can arrive at any time and are only informational
|
|
if (_protocolState != ProtocolState.AwaitingSeed && IncomingPackets.IsInfoPacket(packetId))
|
|
{
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
}
|
|
else
|
|
{
|
|
switch (_protocolState)
|
|
{
|
|
case ProtocolState.AwaitingSeed:
|
|
{
|
|
// Traffic that is positively another protocol. Unlike "does not look like a
|
|
// good client", this cannot misfire on a misconfigured one.
|
|
var foreign = ForeignProtocol.Identify(buffer, out var foreignKind);
|
|
|
|
if (foreign == ForeignProtocolMatch.Incomplete)
|
|
{
|
|
_parserState = ParserState.AwaitingPartialPacket;
|
|
break;
|
|
}
|
|
|
|
if (foreign == ForeignProtocolMatch.Confirmed)
|
|
{
|
|
logger.Debug(
|
|
"{Address} spoke {Protocol} on the game port; disconnecting",
|
|
Address,
|
|
foreignKind
|
|
);
|
|
|
|
if (Bans.BanConfiguration.Settings.ReportBadConnects)
|
|
{
|
|
Bans.BanChannel.Report(
|
|
Address,
|
|
Bans.BanConfiguration.Settings.BadConnectDuration,
|
|
Bans.BanReasons.ForeignProtocol
|
|
);
|
|
}
|
|
|
|
Disconnect(string.Empty);
|
|
return;
|
|
}
|
|
|
|
if (packetId == 0xEF)
|
|
{
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
if (_parserState == ParserState.AwaitingNextPacket)
|
|
{
|
|
_protocolState = ProtocolState.LoginServer_AwaitingLogin;
|
|
}
|
|
}
|
|
else if (length >= 4)
|
|
{
|
|
var newSeed = (packetId << 24) | (packetReader.ReadByte() << 16) | (packetReader.ReadByte() << 8) | packetReader.ReadByte();
|
|
|
|
if (newSeed == 0)
|
|
{
|
|
// No real client sends a zero seed, so this is deliberate garbage
|
|
// rather than a damaged connection — unlike the short-read branch
|
|
// below, which a fragmented first segment can reach honestly.
|
|
if (Bans.BanConfiguration.Settings.ReportBadConnects)
|
|
{
|
|
Bans.BanChannel.Report(
|
|
Address,
|
|
Bans.BanConfiguration.Settings.BadConnectDuration,
|
|
Bans.BanReasons.InvalidSeed
|
|
);
|
|
}
|
|
|
|
Disconnect(string.Empty);
|
|
return;
|
|
}
|
|
|
|
Seed = newSeed;
|
|
Seeded = true;
|
|
packetLength = 4;
|
|
|
|
_parserState = ParserState.AwaitingNextPacket;
|
|
_protocolState = ProtocolState.GameServer_AwaitingGameServerLogin;
|
|
}
|
|
else
|
|
{
|
|
// Disconnect rather than wait. Waiting would hold a connection slot for
|
|
// the full ConnectingSocketIdleLimit per one- or two-byte client, which
|
|
// is what a flood sends. Only pre-0xEF clients reach here.
|
|
Disconnect(string.Empty);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ProtocolState.LoginServer_AwaitingLogin:
|
|
{
|
|
// Check for unencrypted login packet
|
|
if (packetId == 0x80)
|
|
{
|
|
// Unencrypted - check if allowed
|
|
if (EncryptionManager.Enabled && !EncryptionManager.Mode.HasFlag(EncryptionMode.Unencrypted))
|
|
{
|
|
LogInfo("Unencrypted client rejected by encryption policy.");
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
if (_parserState == ParserState.AwaitingNextPacket)
|
|
{
|
|
_protocolState = ProtocolState.LoginServer_AwaitingServerSelect;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// First byte isn't 0x80 - might be encrypted
|
|
if (!EncryptionManager.Enabled)
|
|
{
|
|
LogInfo("Possible encrypted client detected, disconnecting...");
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
// Need 62 bytes for login packet to attempt decryption
|
|
if (length < 62)
|
|
{
|
|
_parserState = ParserState.AwaitingPartialPacket;
|
|
break;
|
|
}
|
|
|
|
// Try to detect and decrypt encrypted login
|
|
if (!this.DetectLoginEncryption(buffer[..62], out var loginEncryption))
|
|
{
|
|
LogInfo("Encrypted client detection failed, disconnecting...");
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
// Decryption succeeded - set up encryption and process
|
|
if (loginEncryption != null)
|
|
{
|
|
_encryption = loginEncryption;
|
|
|
|
// Decrypt the buffer in place for processing
|
|
loginEncryption.ClientDecrypt(buffer[..62]);
|
|
}
|
|
|
|
// Now process as normal (first byte should now be 0x80)
|
|
packetReader = new SpanReader(buffer);
|
|
packetId = packetReader.ReadByte();
|
|
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
if (_parserState == ParserState.AwaitingNextPacket)
|
|
{
|
|
_protocolState = ProtocolState.LoginServer_AwaitingServerSelect;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ProtocolState.LoginServer_AwaitingServerSelect:
|
|
{
|
|
if (packetId != 0xA0)
|
|
{
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
if (_parserState == ParserState.AwaitingNextPacket)
|
|
{
|
|
_protocolState = ProtocolState.LoginServer_ServerSelectAck;
|
|
Disconnect(string.Empty);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ProtocolState.LoginServer_ServerSelectAck:
|
|
{
|
|
#if STRICT_UO_PROTOCOL
|
|
HandleError(packetId, packetLength);
|
|
#else
|
|
// Reset the state because CUO/Orion do not reconnect
|
|
_parserState = ParserState.AwaitingNextPacket;
|
|
_protocolState = ProtocolState.AwaitingSeed;
|
|
#endif
|
|
return;
|
|
}
|
|
|
|
case ProtocolState.GameServer_AwaitingGameServerLogin:
|
|
{
|
|
// Some clients send 0x80 on game server connection
|
|
if (packetId == 0x80 || length == 62)
|
|
{
|
|
goto case ProtocolState.LoginServer_AwaitingLogin;
|
|
}
|
|
|
|
// Check for unencrypted game login packet
|
|
if (packetId == 0x91)
|
|
{
|
|
// Unencrypted - check if allowed
|
|
if (EncryptionManager.Enabled && !EncryptionManager.Mode.HasFlag(EncryptionMode.Unencrypted))
|
|
{
|
|
LogInfo("Unencrypted game client rejected by encryption policy.");
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
if (_parserState == ParserState.AwaitingNextPacket)
|
|
{
|
|
_protocolState = ProtocolState.GameServer_LoggedIn;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// First byte isn't 0x91 - might be encrypted
|
|
if (!EncryptionManager.Enabled)
|
|
{
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
// Need 65 bytes for game login packet to attempt decryption
|
|
if (length < 65)
|
|
{
|
|
_parserState = ParserState.AwaitingPartialPacket;
|
|
break;
|
|
}
|
|
|
|
// Try to detect and decrypt encrypted game login
|
|
if (!this.DetectGameEncryption(buffer[..65], out var gameEncryption))
|
|
{
|
|
LogInfo("Encrypted game client detection failed, disconnecting...");
|
|
HandleError(packetId, packetLength);
|
|
return;
|
|
}
|
|
|
|
// Decryption succeeded - set up encryption and process
|
|
if (gameEncryption != null)
|
|
{
|
|
_encryption = gameEncryption;
|
|
|
|
// Decrypt the buffer in place for processing
|
|
gameEncryption.ClientDecrypt(buffer[..65]);
|
|
}
|
|
|
|
// Now process as normal (first byte should now be 0x91)
|
|
packetReader = new SpanReader(buffer);
|
|
packetId = packetReader.ReadByte();
|
|
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
if (_parserState == ParserState.AwaitingNextPacket)
|
|
{
|
|
_protocolState = ProtocolState.GameServer_LoggedIn;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case ProtocolState.GameServer_LoggedIn:
|
|
{
|
|
_parserState = ParserState.ProcessingPacket;
|
|
_parserState = HandlePacket(packetReader, packetId, out packetLength);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_parserState is ParserState.AwaitingNextPacket)
|
|
{
|
|
_socket.RecvBuffer.CommitRead(packetLength);
|
|
}
|
|
else if (_parserState is ParserState.Throttled)
|
|
{
|
|
if (!throttled)
|
|
{
|
|
_throttled.Enqueue(this);
|
|
}
|
|
else
|
|
{
|
|
_throttledPending.Enqueue(this);
|
|
}
|
|
|
|
break;
|
|
}
|
|
else if (_parserState is ParserState.AwaitingPartialPacket)
|
|
{
|
|
break;
|
|
}
|
|
else if (_parserState is ParserState.Error)
|
|
{
|
|
HandleError(packetId, packetLength);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
#if DEBUG
|
|
Console.WriteLine(ex);
|
|
#endif
|
|
TraceException(ex);
|
|
Disconnect("Exception during HandleReceive");
|
|
}
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
|
private void HandleError(byte packetId, int packetLength)
|
|
{
|
|
var msg =
|
|
$"{this} entered bad state on packet 0x{packetId:X2} with length {packetLength} while in protocol state {_protocolState} and parser state {_parserState}";
|
|
Disconnect(msg);
|
|
_parserState = ParserState.Error;
|
|
_protocolState = ProtocolState.Error;
|
|
}
|
|
|
|
/*
|
|
* length is the total buffer length. We might be able to use packetReader.Capacity() instead.
|
|
* packetLength is the length of the packet that this function actually found.
|
|
*/
|
|
private unsafe ParserState HandlePacket(SpanReader packetReader, byte packetId, out int packetLength)
|
|
{
|
|
var handler = IncomingPackets.GetHandler(packetId);
|
|
var length = packetReader.Length;
|
|
|
|
if (handler == null)
|
|
{
|
|
LogInfo($"Received unknown packet 0x{packetId:X2} while in state {_protocolState}");
|
|
packetLength = 1;
|
|
return ParserState.Error;
|
|
}
|
|
|
|
packetLength = handler.GetLength(this);
|
|
if (packetLength <= 0)
|
|
{
|
|
// Variable length packet. See if we have pulled in the length.
|
|
if (length < 3)
|
|
{
|
|
return ParserState.AwaitingPartialPacket;
|
|
}
|
|
|
|
packetLength = packetReader.ReadUInt16();
|
|
if (packetLength < 3)
|
|
{
|
|
return ParserState.Error;
|
|
}
|
|
}
|
|
|
|
// Not enough data, let's wait for more to come in
|
|
if (length < packetLength)
|
|
{
|
|
return ParserState.AwaitingPartialPacket;
|
|
}
|
|
|
|
if (handler.InGameOnly)
|
|
{
|
|
if (Mobile == null)
|
|
{
|
|
LogInfo($"Received packet 0x{packetId:X2} before having been attached to a mobile.");
|
|
return ParserState.Error;
|
|
}
|
|
|
|
if (Mobile.Deleted)
|
|
{
|
|
LogInfo($"Received packet 0x{packetId:X2} after having been attached to a deleted mobile.");
|
|
return ParserState.Error;
|
|
}
|
|
}
|
|
|
|
if (handler.OutOfGameOnly && Mobile?.Deleted == false)
|
|
{
|
|
LogInfo($"Received packet 0x{packetId:X2} after having been attached to a mobile.");
|
|
return ParserState.Error;
|
|
}
|
|
|
|
var throttler = handler.ThrottleCallback;
|
|
if (throttler != null)
|
|
{
|
|
if (throttler(packetId, this))
|
|
{
|
|
return ParserState.Throttled;
|
|
}
|
|
|
|
SetPacketTime(packetId);
|
|
}
|
|
|
|
UpdatePacketCount(packetId);
|
|
|
|
if (PacketLogging)
|
|
{
|
|
LogPacket(packetReader.Buffer[..packetLength], true);
|
|
}
|
|
|
|
// Make a new SpanReader that is limited to the length of the packet.
|
|
// This allows us to use reader.Remaining for VendorBuyReply packet
|
|
var start = packetReader.Position;
|
|
var remainingLength = packetLength - packetReader.Position;
|
|
|
|
handler.OnReceive(this, new SpanReader(packetReader.Buffer.Slice(start, remainingLength)));
|
|
|
|
return ParserState.AwaitingNextPacket;
|
|
}
|
|
|
|
public void CheckAlive(long curTicks)
|
|
{
|
|
if (_socket == null || NextActivityCheck - curTicks >= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_socket.DisconnectPending)
|
|
{
|
|
LogInfo("Force disconnecting stuck socket...");
|
|
_socketManager.DisconnectImmediate(_socket);
|
|
}
|
|
else
|
|
{
|
|
// Authenticated pre-game clients (login screens): send keep-alive instead of disconnecting.
|
|
// The 0xBD ClientVersionRequest resets NextActivityCheck via DataSent.
|
|
if (_account != null && Mobile == null)
|
|
{
|
|
this.SendClientVersionRequest();
|
|
return;
|
|
}
|
|
|
|
LogInfo("Disconnecting due to inactivity...");
|
|
Disconnect("Disconnecting due to inactivity.");
|
|
}
|
|
}
|
|
|
|
public void Trace(ReadOnlySpan<byte> buffer)
|
|
{
|
|
// We don't have data, so nothing to trace
|
|
if (buffer.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var sw = new StreamWriter("unhandled-packets.log", true);
|
|
sw.WriteLine("Client: {0}: Unhandled packet 0x{1:X2}", this, buffer[0]);
|
|
sw.FormatBuffer(buffer);
|
|
sw.WriteLine();
|
|
sw.WriteLine();
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
public static void TraceException(Exception ex)
|
|
{
|
|
try
|
|
{
|
|
using var op = new StreamWriter("network-errors.log", true);
|
|
op.WriteLine("# {0}", Core.Now);
|
|
|
|
op.WriteLine(ex);
|
|
|
|
op.WriteLine();
|
|
op.WriteLine();
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
|
|
Console.WriteLine(ex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests a graceful disconnect. The disconnect is queued and processed after the flush
|
|
/// queue in Slice(), ensuring Send() calls made in the same tick are processed first.
|
|
/// </summary>
|
|
public void Disconnect(string reason)
|
|
{
|
|
if (!_running || _socket == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disconnectReason = reason;
|
|
|
|
if (!_disconnectQueued)
|
|
{
|
|
_disconnectQueued = true;
|
|
_pendingDisconnects.Enqueue(this);
|
|
}
|
|
}
|
|
|
|
public static void TraceDisconnect(string reason, string ip)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var op = new StreamWriter("network-disconnects.log", true);
|
|
op.WriteLine($"# {Core.Now}");
|
|
|
|
op.WriteLine($"NetState: {ip}");
|
|
op.WriteLine(reason);
|
|
|
|
op.WriteLine();
|
|
op.WriteLine();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TraceException(ex);
|
|
}
|
|
}
|
|
|
|
private void DisposeInternal() => Dispose();
|
|
|
|
// Do not run this directly. Use Disconnect instead.
|
|
// This is available for testing cleanup only.
|
|
[Obsolete("Use Disconnect instead")]
|
|
public void Dispose()
|
|
{
|
|
var wasRunning = _running;
|
|
_running = false;
|
|
// It's possible we could queue for dispose multiple times
|
|
if (_socket == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
TraceDisconnect(_disconnectReason, _toString);
|
|
|
|
// If still running, force immediate disconnect
|
|
if (wasRunning)
|
|
{
|
|
_socketManager?.DisconnectImmediate(_socket);
|
|
}
|
|
|
|
var m = Mobile;
|
|
if (m?.NetState == this)
|
|
{
|
|
m.NetState = null;
|
|
}
|
|
|
|
_instances.Remove(this);
|
|
|
|
// Clear the NetState slot
|
|
var slotId = _socket.Id;
|
|
if (slotId >= 0 && slotId < _netStates.Length && _netStates[slotId] == this)
|
|
{
|
|
_netStates[slotId] = null;
|
|
}
|
|
|
|
// Note: RingSocketManager handles cleanup of ring resources (unregister, close, buffer release)
|
|
// when it processes the disconnect event. We just clear our reference.
|
|
_socket = null;
|
|
|
|
Mobile = null;
|
|
|
|
var a = Account;
|
|
|
|
Menus.Clear();
|
|
HuePickers.Clear();
|
|
Account = null;
|
|
ServerInfo = null;
|
|
CityInfo = null;
|
|
|
|
var count = _instances.Count;
|
|
|
|
LogInfo(a != null ? $"Disconnected. [{count} Online] [{a}]" : $"Disconnected. [{count} Online]");
|
|
}
|
|
}
|