ModernUO/dev-docs/networking-packets.md
Kamron Batman 2dbaa87377
feat: make the blocklist and manual allowlist opt-in; cut the ban subsystem's on-loop cost (#2577)
Two features ran on every shard out of the box, each polling on its own 60s timer for files most shards never generate, neither ever asked for. Fixing that turned into untangling why they shared a config file — and then into the on-loop cost of the three lists behind them.

## Before / after

Measured on the shipped defaults. On-loop numbers are what freezes the world; the tick budget is 8 ms.

| | before | after |
|---|---:|---:|
| Blocklist poll on a shard with no list | every 60s, forever | **none** (opt-in) |
| Manual allowlist poll on a shard with no carve-outs | every 60s, forever | **none** (opt-in) |
| Promote-guard sweep timer | leaked on `Stop()` | stopped, and only started when hits are reported |
| Login allowlist flush, on-loop | O(n) walk + 2 arrays **every 60s**, LOH past ~5,300 entries | reused buffers, **hourly**, zero steady-state allocation |
| Auto-denylist, accept path | 9.1 ns/call | **6.1 ns/call** |
| Auto-denylist, sustained flood at cap (60k rejected) | 26.7 ms | **9.3 ms** |
| Auto-denylist, flood end — **worst single call** | 9.49 ms | **0.05 ms** |
| Auto-denylist cap | 65,536 (stranding 9,895 slots) | **324,449** (exact `HashSet` capacity, ~19 MB) |

The auto-denylist row that matters is the third: the on-loop stall at flood end drops **190×**, because retiring lapsed holds is now the number expiring rather than the number held.

## Why this design

It is built for the shape of attack these shards actually see: **hundreds to a few thousand connections per second**, occasionally tens of thousands, sustained over minutes rather than delivered instantly. Against that shape the cap now covers the whole observed range (50k–250k distinct sources) in memory, and the work of expiring them spreads across the accept calls that were already happening.

There is one case this design is *worse* at than the old one: if every held entry lapses within the same millisecond, retiring them costs ~10.7 ms against the old ~8.9 ms, because the ring's random-access set removals lose to a sequential dictionary scan. Reaching it requires an entire flood to arrive inside one millisecond. **A shard absorbing 324,449 connections in a millisecond is finished at the accept path no matter what this list does** — that is the point where the answer is upstream security and scrubbing (an L4 proxy, edge filtering, a bouncer at the kernel), not a data structure in the game loop. We chose the design that fits the attacks we see and degrades honestly past them, rather than over-engineering for one we do not.

## Blocklist — now opt-in

`BlocklistFilter.Start` only bailed when `_path == null`, which needs `file` to be empty. The default is `"Configuration/ip-blocklist.txt"`, so on any default install both `Task.Run(PollLoop)` and a recurring `SweepGuard` timer started unconditionally, logging *"Blocklist inert: no list at …; polling every 60s"* and then doing exactly that forever.

Adds `"enabled"`, default `false`, using the `_enabled = s.Enabled && <preconditions>` idiom already in `LoginAllowlist` and `AutoDenylist`. **Upgrade is deliberately loud**: a missing key binds to the default, so `LogWhyDisabled()` splits three cases and a shard with a list on disk but no `enabled` key gets a **Warning**, not silence.

## `FileAllowlist` → `ManualAllowlist`, with its own config

Moves to `Configuration/ip-allowlist.json` (`enabled` default `false`, `files`, `reloadInterval`) and into `Network/ManualAllowlist/`, mirroring `Network/LoginAllowlist/`.

It was never a sub-feature of the blocklist. `ManualAllowlist.Contains` has two callers:

| Caller | Could anything else do it? |
|---|---|
| `BlocklistFilter.Evaluate` | **Yes** — the generator already subtracts these files at generation time |
| `BanExemptions.IsExempt` | **No** — sole mechanism for suppressing behavioural ban contributions |

The second reaches `BanChannel.IsExempt` with no blocklist in the path. A shard running **no blocklist** still needs this so the admin's own IP isn't auto-banned by rate-limit detection, so a shared flag couldn't express it — the implication is asymmetric. They still work together via a startup warning when the blocklist is on and the allowlist is not.

On the name: "File" described the storage. The distinction from `LoginAllowlist` is **provenance** — declared by an operator versus earned by authenticating — and "Manual" matches `BanReasons.Manual`. `allowlistFiles` is removed from `BlocklistSettings` outright; blocklists have not shipped long enough for anyone to have set it.

## Login allowlist flush

`Flush()` allocated two arrays sized to the live entry count and copied the whole dictionary into them **on the game loop**, every 60s. `UInt128` is 16 bytes, so past ~5,300 entries that first array was an LOH allocation once a minute, forever. The file write was already off-loop; the walk was not.

Static buffers grown geometrically; the writer owns them until it posts completion back through `Core.LoopContext`, so `_writing`/`_dirty` stay loop state (rule #10). Interval → 1 hour against a 90-day TTL. Clean shutdown writes synchronously via `EventSink.Shutdown`; `HandleClosed` skips `InvokeShutdown` when crashed, so the crash path subscribes separately and only writes when it is actually on the loop thread. Also fixes a pre-existing hole where `_dirty` was cleared *before* the write, so a failed write dropped entries despite the comment promising a retry.

## Auto-denylist: expiry ring

Reclaiming lapsed holds was O(entries held) — every cap-triggered reclaim during a flood walked the whole dictionary to find the few that expired, and `_warnedFull` suppressed the log, not the work.

A hold is **never refreshed** now: the first detection sets the expiry, later ones leave it. That makes insertion order equal to expiry order, so a ring of the same keys is sorted by construction and retiring stops at the first live record. Nothing is lost — the rate limiter runs *ahead* of the connection filters (`NetState.Network.cs`) and reports to the ban channel, so a flooder whose hold lapses is re-held on its next attempt.

Because the ring carries the expiry, the membership side only answers "present?", so it is a `HashSet` — measured at **36 B/slot against the dictionary's 52**. `HashSet` and `Dictionary` share `HashHelpers`, so the from-empty capacity progression is identical (36,353 → 75,431 → 156,437 → 324,449 → 672,827) and the cap still lands on one exactly. The ring is parallel `UInt128[]`/`long[]` rather than an array of structs — `UInt128` forces 16-byte alignment, so a packed pair costs 32 bytes where these cost 24, and the drain reads only the `long[]`.

Rejected after measuring: splitting the drain into a scan loop plus a removal loop (inside noise — both issue N hash removes, and the pointer math was never the bottleneck), and `Dictionary<UInt128,bool>` with tombstoning instead of removal (10% slower *and* unbounded, which breaks the cap).

## Testing

Build clean, 0 warnings. **1,530 tests pass** — 708 UOContent, 822 Server.

Tests were reworked rather than patched: the refresh test inverts to `Repeat_detection_does_not_extend_the_hold`, the obsolete sweep-throttle test is deleted along with the throttle, and four were added for the ring — set/ring parity, release-then-re-hold not being retired by the stale record, exact fill of a non-power-of-two cap, and the moved allowlist config's casing contract. The throttle test added mid-PR was verified to fail without its fix before being deleted.

One commit is comments only (verified: a diff filtered of `//` lines is empty), removing development narration — a `"(Task 2)"` plan reference, `"matching the per-feature JSON config pattern used by X"` across four loaders, a duplicated threading note — and repointing `Firewall` at `dev-docs/ip-bans-and-allowlists.md` instead of a "ban-channel design doc" that does not exist.

Note `Distribution/Configuration/blocklist.json` is gitignored (`.gitignore:14`) and generated from the record defaults on first boot, so the record default *is* the shipped default.
2026-08-13 23:22:35 -07:00

567 lines
22 KiB
Markdown

# 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 on `NetState`
- **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
```csharp
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
```csharp
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
```csharp
// 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
```csharp
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:
```csharp
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()
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
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)
```csharp
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
```csharp
writer.WriteLE(short value);
writer.WriteLE(ushort value);
writer.WriteLE(int value);
writer.WriteLE(uint value);
```
### String Writes
```csharp
// 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
```csharp
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
```csharp
var reader = new SpanReader(ReadOnlySpan<byte> data);
```
### Integer Reads (Big-Endian by Default)
```csharp
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
```csharp
reader.ReadInt16LE();
reader.ReadUInt16LE();
reader.ReadUInt32LE();
```
### String Reads
```csharp
// 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
```csharp
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`
```csharp
// 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`
```csharp
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):
```csharp
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:
```csharp
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](string-handling.md#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:
```csharp
mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold"
```
See [`dev-docs/string-handling.md`](string-handling.md#rawinterpolatedstringhandler) for full coverage.
### Implementation notes
- `Mobile.PublicOverheadMessage` and `Item.PublicOverheadMessage` route through generic `OutgoingMessagePackets.BroadcastMessage*<TFilter>` helpers (in `OutgoingMessagePackets.Broadcast.cs`) parameterized over a `private readonly struct` filter that encapsulates the per-method visibility predicate (`CanSee`, `InLOS`, `AccessLevel`, `!= self`). The `where TFilter : struct, IBroadcastFilter` constraint 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.cs` and `Item.Messages.cs` partial files for organization.
---
## Common Existing Send Methods
### Effects and Sounds
```csharp
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
```csharp
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
```csharp
ns.SendDamage(Serial serial, int amount);
```
### Targeting
```csharp
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
1. **Always check `ns.CannotSendPackets()`** before sending
2. **Use `stackalloc`** for fixed-size packets (avoids heap allocation)
3. **Use `InitializePacket()`** extension on stackalloc spans
4. **Use `ReadAsciiSafe`/`ReadBigUniSafe`** for incoming strings (filters control chars)
5. **Use `WritePacketLength()`** for variable-length packets
6. **Big-endian by default** -- only use `WriteLE`/`ReadLE` when the protocol requires it
7. **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:
```csharp
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:
- `ShouldDeny` must 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, **opt-in**) 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`](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:** `ToUInt128` guards with `AddressFamily == InterNetwork && !IsIPv4MappedToIPv6`.
> Per BCL semantics `IsIPv4MappedToIPv6` is only ever true for `InterNetworkV6`, 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) |