## 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.
22 KiB
ModernUO Networking & Packets
This document covers ModernUO's networking system, including outgoing and incoming packet patterns, SpanWriter/SpanReader, and NetState extensions.
Overview
ModernUO uses a binary packet protocol for client-server communication. The system is built around:
- Outgoing packets: Static
Create*methods +Send*extension methods onNetState - Incoming packets: Function pointer handlers registered in
Configure() - SpanWriter/SpanReader: High-performance binary I/O using
Span<byte>
Outgoing Packet Pattern
Step 1: Define Constants and Create Method
public static class OutgoingMyPackets
{
public const int MyPacketLength = 12; // Fixed-size packet
public static void CreateMyPacket(Span<byte> buffer, Serial target, int value)
{
if (buffer[0] != 0) // Already initialized guard
return;
var writer = new SpanWriter(buffer);
writer.Write((byte)0xBF); // Packet ID
writer.Write((ushort)12); // Packet length
writer.Write((ushort)0x99); // Sub-command
writer.Write(target); // Serial (4 bytes)
writer.Write((short)value); // Value (2 bytes)
}
}
Step 2: Define Send Extension Method
public static void SendMyPacket(this NetState ns, Serial target, int value)
{
if (ns.CannotSendPackets())
return;
var buffer = stackalloc byte[MyPacketLength].InitializePacket();
CreateMyPacket(buffer, target, value);
ns.Send(buffer);
}
Step 3: Call from Game Code
// Send to one player
mobile.NetState.SendMyPacket(target.Serial, 42);
// Send to nearby players
foreach (var ns in mobile.GetClientsInRange(18))
{
ns.SendMyPacket(target.Serial, 42);
}
Variable-Length Outgoing Packets
public static void SendMyDynamicPacket(this NetState ns, string name, int[] values)
{
if (ns.CannotSendPackets())
return;
var length = 7 + name.Length * 2 + values.Length * 4;
var writer = new SpanWriter(stackalloc byte[length]);
writer.Write((byte)0x99); // Packet ID
writer.Write((ushort)0); // Length placeholder
writer.WriteBigUniNull(name); // Unicode string
writer.Write((ushort)values.Length);
foreach (var val in values)
writer.Write(val);
writer.WritePacketLength(); // Fill in actual length at position 1-2
ns.Send(writer.Span);
}
Shared Buffer Pattern (Multiple Recipients)
When sending the same packet to multiple players, create the buffer once:
public static void SendToNearby(Mobile source, int effectId)
{
Span<byte> buffer = stackalloc byte[EffectPacketLength];
buffer.InitializePacket();
foreach (var ns in source.GetClientsInRange(18))
{
// CreateXxx checks buffer[0] != 0 to avoid re-initializing
CreateEffectPacket(buffer, source.Serial, effectId);
ns.Send(buffer);
}
}
Incoming Packet Pattern
Step 1: Register Handler in Configure()
public static class IncomingMyPackets
{
public static unsafe void Configure()
{
// Fixed-length packet (12 bytes, in-game only)
IncomingPackets.Register(0x99, 12, true, &MyHandler);
// Variable-length packet (0 = variable)
IncomingPackets.Register(0x9A, 0, true, &MyDynamicHandler);
// Out-of-game packet
IncomingPackets.Register(0x9B, 10, false, &LoginHandler);
// Encoded packet (sub-command)
IncomingPackets.RegisterEncoded(0x28, true, &EncodedHandler);
}
}
Step 2: Implement Handler
public static void MyHandler(NetState state, SpanReader reader)
{
var from = state.Mobile;
if (from == null)
return;
var targetSerial = (Serial)reader.ReadUInt32();
var value = reader.ReadInt16();
var target = World.FindMobile(targetSerial);
if (target == null)
return;
// Process packet...
}
public static void MyDynamicHandler(NetState state, SpanReader reader)
{
var from = state.Mobile;
if (from == null)
return;
var name = reader.ReadBigUniSafe();
var count = reader.ReadUInt16();
for (var i = 0; i < count; i++)
{
var val = reader.ReadInt32();
// Process each value...
}
}
Encoded Packet Handler
public static void EncodedHandler(NetState state, IEntity target, EncodedReader reader)
{
// Encoded packets have a different signature
var from = state.Mobile;
if (from == null)
return;
// Process...
}
Registration Parameters
IncomingPackets.Register(
int packetID, // Packet identifier (0x00-0xFF)
int length, // Fixed length, or 0 for variable-length
bool inGameOnly, // Requires authenticated player
delegate*<NetState, SpanReader, void> handler // Function pointer
);
SpanWriter Reference
High-performance ref struct for writing binary data. Defined in Projects/Server/Buffers/SpanWriter.cs.
Constructors
var writer = new SpanWriter(Span<byte> buffer); // Fixed buffer
var writer = new SpanWriter(stackalloc byte[64]); // Stack buffer
var writer = new SpanWriter(int capacity, bool resize = false); // Pooled buffer
Integer Writes (Big-Endian by Default)
writer.Write(bool value); // 1 byte
writer.Write(byte value); // 1 byte
writer.Write(sbyte value); // 1 byte
writer.Write(short value); // 2 bytes, big-endian
writer.Write(ushort value); // 2 bytes, big-endian
writer.Write(int value); // 4 bytes, big-endian
writer.Write(uint value); // 4 bytes, big-endian
writer.Write(long value); // 8 bytes, big-endian
writer.Write(ulong value); // 8 bytes, big-endian
writer.Write(Serial serial); // 4 bytes (writes serial.Value)
Little-Endian Variants
writer.WriteLE(short value);
writer.WriteLE(ushort value);
writer.WriteLE(int value);
writer.WriteLE(uint value);
String Writes
// ASCII (1 byte per char)
writer.WriteAscii(string value);
writer.WriteAsciiNull(string value); // Null-terminated
writer.WriteAscii(string value, int fixedLength);
// Latin-1 (1 byte per char, extended ASCII)
writer.WriteLatin1(string value);
writer.WriteLatin1Null(string value);
writer.WriteLatin1(string value, int fixedLength);
// UTF-16 Big-Endian (UO standard for Unicode)
writer.WriteBigUni(string value);
writer.WriteBigUniNull(string value);
writer.WriteBigUni(string value, int fixedLength);
// UTF-16 Little-Endian
writer.WriteLittleUni(string value);
writer.WriteLittleUniNull(string value);
writer.WriteLittleUni(string value, int fixedLength);
// UTF-8
writer.WriteUTF8(string value);
writer.WriteUTF8Null(string value);
Utilities
writer.Write(ReadOnlySpan<byte> data); // Raw bytes
writer.Clear(int count); // Write zeros
writer.Seek(int offset, SeekOrigin origin); // Move position
writer.WritePacketLength(); // Fill length at position 1-2
writer.EnsureCapacity(int capacity); // Grow buffer if needed
writer.Dispose(); // Return pooled buffer
// Properties
writer.Position; // Current write position
writer.Capacity; // Buffer size
writer.Span; // ReadOnlySpan<byte> of written data
writer.RawBuffer; // Mutable Span<byte> of full buffer
SpanReader Reference
High-performance ref struct for reading binary data. Defined in Projects/Server/Buffers/SpanReader.cs.
Constructor
var reader = new SpanReader(ReadOnlySpan<byte> data);
Integer Reads (Big-Endian by Default)
reader.ReadByte(); // 1 byte
reader.ReadBoolean(); // 1 byte (> 0 = true)
reader.ReadSByte(); // 1 byte signed
reader.ReadInt16(); // 2 bytes, big-endian
reader.ReadUInt16(); // 2 bytes, big-endian
reader.ReadInt32(); // 4 bytes, big-endian
reader.ReadUInt32(); // 4 bytes, big-endian
reader.ReadInt64(); // 8 bytes, big-endian
reader.ReadUInt64(); // 8 bytes, big-endian
Little-Endian Variants
reader.ReadInt16LE();
reader.ReadUInt16LE();
reader.ReadUInt32LE();
String Reads
// Each has a "Safe" variant that filters control characters
reader.ReadAscii(int fixedLength = -1);
reader.ReadAsciiSafe(int fixedLength = -1);
reader.ReadLatin1(int fixedLength = -1);
reader.ReadLatin1Safe(int fixedLength = -1);
reader.ReadBigUni(int fixedLength = -1);
reader.ReadBigUniSafe(int fixedLength = -1);
reader.ReadLittleUni(int fixedLength = -1);
reader.ReadLittleUniSafe(int fixedLength = -1);
reader.ReadUTF8(int fixedLength = -1);
reader.ReadUTF8Safe(int fixedLength = -1);
Utilities
reader.Seek(int offset, SeekOrigin origin);
reader.Read(Span<byte> destination);
// Properties
reader.Position; // Current read position
reader.Length; // Total data length
reader.Remaining; // Bytes remaining
reader.Buffer; // ReadOnlySpan<byte> of full data
Player-Facing Message APIs
For chat, system messages, and overhead text, prefer the high-level convenience methods on Mobile and Item — they handle stackalloc sizing, packet buffer initialization, spatial queries, and visibility filtering for you. The underlying packets all live in OutgoingMessagePackets.
On Mobile
// Self-message (sent only to this mobile's NetState)
mob.SendMessage(int hue, ReadOnlySpan<char> text);
mob.SendAsciiMessage(int hue, ReadOnlySpan<char> text);
mob.SendLocalizedMessage(int number, ReadOnlySpan<char> args = default, int hue = 0x3B2);
mob.SendLocalizedMessage(int number, bool append, ReadOnlySpan<char> affix, ReadOnlySpan<char> args = default, int hue = 0x3B2);
// Speech variants (overhead text from this mobile, broadcast in range)
mob.Say(ReadOnlySpan<char> text); // SpeechHue
mob.Say(int number, ReadOnlySpan<char> args = default);
mob.Emote(ReadOnlySpan<char> text); // EmoteHue
mob.Whisper(ReadOnlySpan<char> text); // WhisperHue, short range
mob.Yell(ReadOnlySpan<char> text); // YellHue, long range
// Targeted overhead messages
mob.PublicOverheadMessage(MessageType type, int hue, bool ascii, ReadOnlySpan<char> text, bool noLineOfSight = true, AccessLevel accessLevel = AccessLevel.Player);
mob.PublicOverheadMessage(MessageType type, int hue, int number, ReadOnlySpan<char> args = default, bool noLineOfSight = true);
mob.PrivateOverheadMessage(MessageType type, int hue, int number, ReadOnlySpan<char> args, NetState state);
mob.LocalOverheadMessage(MessageType type, int hue, bool ascii, ReadOnlySpan<char> text);
mob.NonlocalOverheadMessage(MessageType type, int hue, int number, ReadOnlySpan<char> args = default);
On Item
item.PublicOverheadMessage(MessageType type, int hue, bool ascii, ReadOnlySpan<char> text);
item.PublicOverheadMessage(MessageType type, int hue, int number, ReadOnlySpan<char> args = default);
item.SendLocalizedMessageTo(Mobile to, int number, ReadOnlySpan<char> args = default);
item.SendLocalizedMessageTo(Mobile to, int number, int hue, ReadOnlySpan<char> args = default);
item.SendMessageTo(Mobile to, ReadOnlySpan<char> text, int hue = 0x3B2);
Direct NetState extensions
When you have a NetState and need full control (custom serial, body, font, language):
ns.SendMessage(Serial serial, int graphic, MessageType type, int hue, int font, bool ascii, string lang, ReadOnlySpan<char> name, ReadOnlySpan<char> text);
ns.SendMessageLocalized(Serial serial, int graphic, MessageType type, int hue, int font, int number, ReadOnlySpan<char> name = default, ReadOnlySpan<char> args = default);
ns.SendMessageLocalizedAffix(Serial serial, int graphic, MessageType type, int hue, int font, int number, ReadOnlySpan<char> name, AffixType affixType, ReadOnlySpan<char> affix = default, ReadOnlySpan<char> args = default);
Zero-allocation interpolation overloads
Every method above has a ref RawInterpolatedStringHandler overload for the text/args parameter. When the call-site argument is a $"..." literal, the compiler picks the handler overload and the message text is rendered directly into a pooled char[] — no string allocation:
mob.SendMessage($"You have {gold:N0} gold and {bounty:N0} bounty");
mob.Say($"Hello, {target.Name}!");
item.SendLocalizedMessageTo(player, cliloc, $"{a}\t{b}");
mob.PublicOverheadMessage(MessageType.Regular, hue, false, $"I am {mob.Name}");
When the argument is a pre-built string or ReadOnlySpan<char> variable, the ROS<char> overload is selected via implicit conversion — also fine, just doesn't get the zero-alloc benefit.
For methods with two text parameters (SendLocalizedMessageTo with affix, SendLocalizedMessage with append), only args has a handler overload — affix stays ROS<char> because it's typically a short literal.
Critical caveat: call-site shapes like ternaries, switch expressions, pre-built locals, and .ToString() inside the hole silently defeat the handler overload selection. See the Interpolation Anti-Patterns section in the string-handling doc for the full list and the fixes.
Lowercase format specifier
RawInterpolatedStringHandler recognizes :L to lowercase a value's output (using MemoryExtensions.ToLowerInvariant). Useful for enum names in player-facing text:
mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold"
See dev-docs/string-handling.md for full coverage.
Implementation notes
Mobile.PublicOverheadMessageandItem.PublicOverheadMessageroute through genericOutgoingMessagePackets.BroadcastMessage*<TFilter>helpers (inOutgoingMessagePackets.Broadcast.cs) parameterized over aprivate readonly structfilter that encapsulates the per-method visibility predicate (CanSee,InLOS,AccessLevel,!= self). Thewhere TFilter : struct, IBroadcastFilterconstraint specializes per filter type and keeps the dispatch zero-allocation (no boxing, no virtual call — JIT inlines the predicate).- The convenience methods themselves live in
Mobile.Messages.csandItem.Messages.cspartial files for organization.
Common Existing Send Methods
Effects and Sounds
ns.SendSoundEffect(int soundID, IPoint3D target);
ns.SendMobileAnimation(Serial mobile, int action, int frames, int repeat, bool forward, bool loop, int delay);
ns.SendNewMobileAnimation(Serial mobile, int action, int frames, int delay);
Mobile Status
ns.SendMobileHits(Mobile m, bool normalize = false);
ns.SendMobileMana(Mobile m, bool normalize = false);
ns.SendMobileStam(Mobile m, bool normalize = false);
ns.SendMobileAttributes(Mobile m, bool normalize = false);
ns.SendMobileStatus(Mobile m);
ns.SendMobileName(Mobile m);
ns.SendMobileMoving(Mobile source, Mobile target);
ns.SendBondedStatus(Serial serial, bool bonded);
ns.SendDeathAnimation(Serial killed, Serial corpse);
Damage
ns.SendDamage(Serial serial, int amount);
Targeting
ns.SendTargetReq(Target target);
ns.SendMovementRej(int sequence, Mobile m);
Protocol Notes
- Endianness: UO protocol is big-endian by default
- Packet ID: First byte identifies the packet type (0x00-0xFF)
- Length: For variable-length packets, bytes 1-2 are the total length (big-endian ushort)
- Serials: 4-byte identifiers for items (0x40000000+) and mobiles (0x00000001+)
- Clilocs: 4-byte localized string IDs
Best Practices
- Always check
ns.CannotSendPackets()before sending - Use
stackallocfor fixed-size packets (avoids heap allocation) - Use
InitializePacket()extension on stackalloc spans - Use
ReadAsciiSafe/ReadBigUniSafefor incoming strings (filters control chars) - Use
WritePacketLength()for variable-length packets - Big-endian by default -- only use
WriteLE/ReadLEwhen the protocol requires it - Function pointers (
&Handler) for incoming packet registration (no delegate allocation)
Connection Filtering (Accept Path)
Every inbound socket is checked before it becomes a NetState. The check runs on the game loop once
per accepted connection -- this is the path that has to survive a DDoS -- so it must be allocation-free
and non-blocking.
Gates plug in through IConnectionFilter, registered with ConnectionFilters.Register() during the
Configure sweep:
public sealed class MyFilter : IConnectionFilter
{
public string Name => "my-filter";
public void Configure() { /* read config, no I/O */ }
public void Start(CancellationToken token) { /* background hydration */ }
public void Stop() { }
public bool ShouldDeny(IPAddress address) => /* allocation-free membership test */;
}
// In a static Configure() so the sweep finds it:
ConnectionFilters.Register(new MyFilter());
Rules:
ShouldDenymust be allocation-free, O(log n) at worst, no I/O, no blocking. Anything expensive (parsing, reloading, contributing to an external service) belongs off the loop or behind a bounded, non-blocking enqueue.- Side effects a hit implies (reporting to
BanChannel, promoting to an OS firewall, suppressing duplicate reports) are the filter's business, not the accept path's. - Filters are consulted in registration order and the first denial short-circuits, so register the cheapest and most selective first. Order affects only how quickly a denial is reached, never whether one happens.
- A filter that throws is unregistered and the connection fails open. A filter that faults once faults for every connection, so leaving it registered would mean an exception per accept.
Core owns the question; every implementation lives in UOContent. The three that ship are firewall
(admin-curated, mutable at runtime, persisted to Configuration/firewall.json), blocklist (file-sourced,
millions of entries, demand-pages hits to CrowdSec) and auto-denylist (in-memory, short-lived, fed by the
shard's own behavioural detections). A shard that fronts its server with an upstream proxy or edge scrubbing
can drop all of them and register nothing.
The allowlists, ban contribution, behavioural detection and the operator process for exempting a
false-positive address are covered separately in
dev-docs/ip-bans-and-allowlists.md.
Do not route this kind of check through EventSink.InvokeSocketConnect -- that fires later and
allocates a SocketConnectEventArgs per connection, which is exactly what the accept path avoids for
rejected traffic.
IP Address Normalization (IPAddressUtility)
Addresses are normalized to UInt128 in IPv6 form so a single comparison/index works for both
families. An IPv4 address becomes its v4-mapped-v6 value (::ffff:a.b.c.d), which is why round-tripping
matters: IPv4 -> UInt128 -> IPAddress can come back as InterNetworkV6 with IsIPv4MappedToIPv6
set, even though it is "really" a v4 address. Code that switches on AddressFamily alone will mis-handle
those, so the helpers check both.
Known wart / follow-up:
ToUInt128guards withAddressFamily == InterNetwork && !IsIPv4MappedToIPv6. Per BCL semanticsIsIPv4MappedToIPv6is only ever true forInterNetworkV6, so the second clause reads as redundant -- it is really defending the round-trip described above. The normalization would be clearer as an explicit "to canonical v6 bits" step that never needs the family check at all. Deliberately left as-is; to be revisited in a follow-up PR rather than churned mid-feature.
Key File References
| File | Description |
|---|---|
Projects/Server/Buffers/SpanWriter.cs |
SpanWriter ref struct |
Projects/Server/Buffers/SpanReader.cs |
SpanReader ref struct |
Projects/Server/Network/Packets/IncomingPackets.cs |
Packet registration |
Projects/UOContent/Network/Packets/IncomingPlayerPackets.cs |
Player packet handlers |
Projects/UOContent/Network/Packets/IncomingMovementPackets.cs |
Movement handlers |
Projects/UOContent/Network/Packets/IncomingMessagePackets.cs |
Speech handlers |
Projects/UOContent/Network/Packets/IncomingItemPackets.cs |
Item handlers |
Projects/UOContent/Network/Packets/IncomingTargetingPackets.cs |
Targeting handlers |
Projects/Server/Network/Packets/OutgoingMobilePackets.cs |
Mobile packets |
Projects/Server/Network/Packets/OutgoingItemPackets.cs |
Item packets |
Projects/Server/Network/Packets/OutgoingDamagePackets.cs |
Damage packets |
Projects/Server/Network/Packets/OutgoingEffectPackets.cs |
Effect/sound packets |
Projects/Server/Network/Packets/OutgoingAccountPackets.cs |
Account packets |
Projects/Server/Network/Packets/OutgoingContainerPackets.cs |
Container packets |
Projects/Server/Network/PacketHandler.cs |
PacketHandler class |
Projects/Server/Network/IConnectionFilter.cs |
Accept-path gate contract |
Projects/Server/Network/ConnectionFilters.cs |
Filter registry + lifecycle |
Projects/UOContent/Network/Firewall/Firewall.cs |
Admin-curated firewall set |
Projects/Server/Utilities/IPAddressUtility.cs |
IPAddress <-> UInt128 normalization, CIDR parsing |
Projects/UOContent/Network/Blocklist/BlocklistFilter.cs |
File-sourced blocklist filter |
Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs |
Allowlist earned by a recent successful login |
Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs |
Short-lived local hold on behavioural detections |
Projects/Server/Network/Bans/BanReasons.cs |
Ban reason slugs + the behavioural opt-in set |
Projects/Server/Network/ForeignProtocol.cs |
Positive identification of non-UO traffic (HTTP/TLS/SSH) |