diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs
new file mode 100644
index 000000000..01a3707c7
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistAcceptGateTests.cs
@@ -0,0 +1,50 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: BlocklistAcceptGateTests.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System.Net;
+using Server.Network.Bans.Blocklist;
+using Xunit;
+
+namespace Server.Tests.Network.Bans.Blocklist;
+
+[Collection("Sequential Server Tests")]
+public class BlocklistAcceptGateTests
+{
+ [Fact]
+ public void Blocklisted_ip_denied_and_reported_once()
+ {
+ FileBlocklist.LoadForTesting(BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes("1.2.3.4"), out _, out _));
+ var guard = new PromotedGuard();
+ var ip = IPAddress.Parse("1.2.3.4");
+
+ var deny1 = BlocklistGate.Evaluate(ip, false, guard, 1000, true, 5000, out var r1);
+ var deny2 = BlocklistGate.Evaluate(ip, false, guard, 1500, true, 5000, out var r2);
+
+ Assert.True(deny1);
+ Assert.True(r1);
+ Assert.True(deny2);
+ Assert.False(r2); // denied both, reported once within TTL
+ }
+
+ [Fact]
+ public void Whitelisted_and_clean_ips_pass()
+ {
+ FileBlocklist.LoadForTesting(BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes("1.2.3.4"), out _, out _));
+ var guard = new PromotedGuard();
+
+ Assert.False(BlocklistGate.Evaluate(IPAddress.Parse("1.2.3.4"), true, guard, 1, true, 5000, out _));
+ Assert.False(BlocklistGate.Evaluate(IPAddress.Parse("9.9.9.9"), false, guard, 1, true, 5000, out _));
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs
new file mode 100644
index 000000000..4ecd64ee7
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistBanSettingsTests.cs
@@ -0,0 +1,20 @@
+using System;
+using Server.Network.Bans;
+using Xunit;
+
+namespace Server.Tests.Network.Bans.Blocklist;
+
+public class BlocklistBanSettingsTests
+{
+ [Fact]
+ public void Blocklist_defaults_are_present()
+ {
+ var s = new BanSettings();
+
+ Assert.Equal("", s.BlocklistFile);
+ Assert.Equal(TimeSpan.FromSeconds(60), s.BlocklistReloadInterval);
+ Assert.True(s.ReportBlocklistHits);
+ Assert.Equal(TimeSpan.FromHours(6), s.BlocklistBanDuration);
+ Assert.Equal(TimeSpan.FromSeconds(60), s.BlocklistPromoteSuppression);
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs
new file mode 100644
index 000000000..c732d5365
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs
@@ -0,0 +1,63 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: BlocklistFileTests.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.IO;
+using System.Net;
+using Server.Network.Bans.Blocklist;
+using Xunit;
+
+namespace Server.Tests.Network.Bans.Blocklist;
+
+public class BlocklistFileTests
+{
+ private static string WriteTemp(string content)
+ {
+ var p = Path.Combine(Path.GetTempPath(), "bl-" + Guid.NewGuid().ToString("N") + ".txt");
+ File.WriteAllText(p, content);
+ return p;
+ }
+
+ [Fact]
+ public void Reads_header_generated_and_count()
+ {
+ var p = WriteTemp("# modernuo-blocklist v1 generated=2026-07-21T09:24:23Z count=2\n1.2.3.4\n5.6.7.0/24\n");
+ Assert.True(BlocklistFile.TryReadHeader(p, out var h));
+ Assert.True(h.Present);
+ Assert.Equal("2026-07-21T09:24:23Z", h.Generated);
+ Assert.Equal(2, h.Count);
+ File.Delete(p);
+ }
+
+ [Fact]
+ public void Missing_file_reports_absent_and_loads_empty()
+ {
+ var p = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N"));
+ Assert.False(BlocklistFile.TryReadHeader(p, out var h));
+ Assert.False(h.Present);
+ var snap = BlocklistFile.Load(p, out _, out _);
+ Assert.False(snap.IsBanned(IPAddress.Parse("1.2.3.4")));
+ }
+
+ [Fact]
+ public void Load_parses_body()
+ {
+ var p = WriteTemp("# generated=x count=1\n8.8.8.0/24\n");
+ var snap = BlocklistFile.Load(p, out var parsed, out _);
+ Assert.Equal(1, parsed);
+ Assert.True(snap.IsBanned(IPAddress.Parse("8.8.8.8")));
+ File.Delete(p);
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs
new file mode 100644
index 000000000..4cc83cdfd
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs
@@ -0,0 +1,98 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: BlocklistSnapshotTests.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System.Net;
+using Server.Network.Bans.Blocklist;
+using Xunit;
+
+namespace Server.Tests.Network.Bans.Blocklist;
+
+public class BlocklistSnapshotTests
+{
+ private static BlocklistSnapshot Build(params string[] lines) =>
+ BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes(string.Join('\n', lines)), out _, out _);
+
+ [Fact]
+ public void Single_ip_is_matched()
+ {
+ var s = Build("1.2.3.4");
+ Assert.True(s.IsBanned(IPAddress.Parse("1.2.3.4")));
+ Assert.False(s.IsBanned(IPAddress.Parse("1.2.3.5")));
+ }
+
+ [Fact]
+ public void Cidr_contains_and_excludes_boundaries()
+ {
+ var s = Build("10.0.0.0/24");
+ Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.0")));
+ Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.255")));
+ Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0")));
+ Assert.False(s.IsBanned(IPAddress.Parse("9.255.255.255")));
+ }
+
+ [Fact]
+ public void Comments_blanks_and_garbage_are_skipped_not_thrown()
+ {
+ var s = BlocklistSnapshot.Build(
+ System.Text.Encoding.ASCII.GetBytes(
+ string.Join('\n', "# header generated=x", "", "not-an-ip", "1.2.3.4", "::1", "5.6.7.0/24")),
+ out var parsed, out var skipped);
+ Assert.Equal(3, parsed); // 1.2.3.4 + ::1 (valid loopback) + 5.6.7.0/24
+ Assert.True(skipped >= 1); // "not-an-ip"; blank/comment lines are silently skipped, not counted
+ Assert.True(s.IsBanned(IPAddress.Parse("5.6.7.200")));
+ }
+
+ [Fact]
+ public void Empty_snapshot_matches_nothing()
+ {
+ Assert.False(BlocklistSnapshot.Empty.IsBanned(IPAddress.Parse("1.2.3.4")));
+ }
+
+ [Fact]
+ public void Ipv6_single_and_cidr_are_matched()
+ {
+ var s = Build("2001:db8::1", "2001:db8:1::/48");
+ Assert.True(s.IsBanned(IPAddress.Parse("2001:db8::1")));
+ Assert.True(s.IsBanned(IPAddress.Parse("2001:db8:1::abcd")));
+ Assert.False(s.IsBanned(IPAddress.Parse("2001:db8:2::1")));
+ }
+
+ [Fact]
+ public void Ipv4_mapped_ipv6_is_normalized_to_v4()
+ {
+ var s = Build("1.2.3.4");
+ Assert.True(s.IsBanned(IPAddress.Parse("::ffff:1.2.3.4"))); // must not bypass the v4 set
+ }
+
+ [Fact]
+ public void Nested_cidr_intervals_are_coalesced()
+ {
+ // A /32 nested inside a /24: InRange's binary search only inspects the
+ // rightmost interval starting <= ip, so without coalescing an IP inside
+ // the /24 but outside the /32 would land on the /32 and wrongly pass.
+ var s = Build("10.0.0.0/24", "10.0.0.5/32");
+ Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // inside /24, outside /32
+ Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.5"))); // the nested /32 itself
+ Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0"))); // genuinely outside both
+ }
+
+ [Fact]
+ public void Overlapping_cidr_intervals_are_coalesced()
+ {
+ var s = Build("10.0.0.0/25", "10.0.0.64/25");
+ Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // covered by the second /25
+ Assert.False(s.IsBanned(IPAddress.Parse("10.0.0.200"))); // outside both
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs
new file mode 100644
index 000000000..0f0e703e6
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/FileBlocklistTests.cs
@@ -0,0 +1,39 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: FileBlocklistTests.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System.Net;
+using Server.Network.Bans.Blocklist;
+using Xunit;
+
+namespace Server.Tests.Network.Bans.Blocklist;
+
+[Collection("Sequential Server Tests")]
+public class FileBlocklistTests
+{
+ [Fact]
+ public void IsBanned_reflects_loaded_snapshot()
+ {
+ FileBlocklist.LoadForTesting(BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes("1.2.3.4"), out _, out _));
+ Assert.True(FileBlocklist.IsBanned(IPAddress.Parse("1.2.3.4")));
+ Assert.False(FileBlocklist.IsBanned(IPAddress.Parse("1.2.3.5")));
+ }
+
+ [Fact]
+ public void Empty_when_unloaded_never_throws()
+ {
+ FileBlocklist.LoadForTesting(BlocklistSnapshot.Empty);
+ Assert.False(FileBlocklist.IsBanned(IPAddress.Parse("8.8.8.8")));
+ }
+}
diff --git a/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs
new file mode 100644
index 000000000..5f30818b7
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs
@@ -0,0 +1,46 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: PromotedGuardTests.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using Server.Network.Bans.Blocklist;
+using Xunit;
+
+namespace Server.Tests.Network.Bans.Blocklist;
+
+public class PromotedGuardTests
+{
+ [Fact]
+ public void First_mark_true_then_suppressed_until_ttl()
+ {
+ var g = new PromotedGuard();
+ Assert.True(g.TryMark((UInt128)42, 1000, 5000));
+ Assert.False(g.TryMark((UInt128)42, 2000, 5000)); // within TTL
+ Assert.True(g.TryMark((UInt128)42, 6001, 5000)); // expired → re-mark
+ }
+
+ [Fact]
+ public void Sweep_removes_expired_entries_allowing_remark()
+ {
+ var g = new PromotedGuard();
+ Assert.True(g.TryMark((UInt128)7, 0, 1000));
+ Assert.False(g.TryMark((UInt128)7, 500, 1000)); // still within TTL
+
+ g.Sweep(500); // not yet expired, sweep should not remove it
+ Assert.False(g.TryMark((UInt128)7, 999, 1000));
+
+ g.Sweep(1001); // now expired, sweep removes it
+ Assert.True(g.TryMark((UInt128)7, 1002, 1000)); // fresh mark, not "still marked"
+ }
+}
diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs
new file mode 100644
index 000000000..6fbc4a12a
--- /dev/null
+++ b/Projects/Server/Network/Bans/Blocklist/BlocklistFile.cs
@@ -0,0 +1,84 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: BlocklistFile.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.IO;
+
+namespace Server.Network.Bans.Blocklist;
+
+public readonly record struct BlocklistHeader(string Generated, int Count, bool Present);
+
+/// Reads the versioned blocklist file: cheap header probe + full snapshot load.
+public static class BlocklistFile
+{
+ public static bool TryReadHeader(string path, out BlocklistHeader header)
+ {
+ header = new BlocklistHeader(null, 0, false);
+ try
+ {
+ if (!File.Exists(path))
+ {
+ return false;
+ }
+ using var reader = new StreamReader(path);
+ var first = reader.ReadLine();
+ if (first == null)
+ {
+ header = new BlocklistHeader(null, 0, true);
+ return true;
+ }
+ string generated = null;
+ var count = 0;
+ if (first.StartsWith('#'))
+ {
+ foreach (var tok in first.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (tok.StartsWith("generated=", StringComparison.Ordinal))
+ {
+ generated = tok["generated=".Length..];
+ }
+ else if (tok.StartsWith("count=", StringComparison.Ordinal))
+ {
+ int.TryParse(tok["count=".Length..], out count);
+ }
+ }
+ }
+ header = new BlocklistHeader(generated, count, true);
+ return true;
+ }
+ catch
+ {
+ return false; // treat as absent; caller keeps last-good / empty
+ }
+ }
+
+ public static BlocklistSnapshot Load(string path, out int parsed, out int skipped)
+ {
+ parsed = 0;
+ skipped = 0;
+ try
+ {
+ if (!File.Exists(path))
+ {
+ return BlocklistSnapshot.Empty;
+ }
+ return BlocklistSnapshot.Build(File.ReadAllBytes(path), out parsed, out skipped);
+ }
+ catch
+ {
+ return BlocklistSnapshot.Empty;
+ }
+ }
+}
diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs
new file mode 100644
index 000000000..682cbc8b0
--- /dev/null
+++ b/Projects/Server/Network/Bans/Blocklist/BlocklistGate.cs
@@ -0,0 +1,42 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: BlocklistGate.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System.Net;
+
+namespace Server.Network.Bans.Blocklist;
+
+///
+/// Pure accept-gate decision, isolated for testability. Allocation-free: no closures on the accept
+/// path. Returns true when the connection should be denied; indicates
+/// whether this hit should be contributed to the ban channel (once per TTL).
+///
+public static class BlocklistGate
+{
+ public static bool Evaluate(
+ IPAddress ip, bool whitelisted, PromotedGuard guard, long nowTicks,
+ bool reportHits, long ttlMs, out bool shouldReport)
+ {
+ shouldReport = false;
+ if (whitelisted || !FileBlocklist.IsBanned(ip))
+ {
+ return false; // pass
+ }
+ if (reportHits)
+ {
+ shouldReport = guard.TryMark(ip.ToUInt128(), nowTicks, ttlMs);
+ }
+ return true; // deny
+ }
+}
diff --git a/Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs b/Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs
new file mode 100644
index 000000000..e2e6857a8
--- /dev/null
+++ b/Projects/Server/Network/Bans/Blocklist/BlocklistSnapshot.cs
@@ -0,0 +1,205 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: BlocklistSnapshot.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.Buffers.Text;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Server.Collections;
+
+namespace Server.Network.Bans.Blocklist;
+
+///
+/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced
+/// interval index per family: IPv4 as ranges (lean for the millions-strong common
+/// case), IPv6 as ranges (empty unless the feed carries v6). Immutable → lock-free reads.
+///
+public sealed class BlocklistSnapshot
+{
+ public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex.Empty, SortedRangeIndex.Empty);
+
+ private readonly SortedRangeIndex _v4;
+ private readonly SortedRangeIndex _v6;
+
+ public int Count => _v4.Count + _v6.Count;
+
+ private BlocklistSnapshot(SortedRangeIndex v4, SortedRangeIndex v6)
+ {
+ _v4 = v4;
+ _v6 = v6;
+ }
+
+ ///
+ /// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on
+ /// '\n' with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the
+ /// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser.
+ /// Malformed lines increment and never throw. Build-time intermediates use
+ /// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread.
+ ///
+ public static BlocklistSnapshot Build(ReadOnlySpan data, out int parsed, out int skipped)
+ {
+ parsed = 0;
+ skipped = 0;
+
+ // Only the two final index arrays (allocated inside SortedRangeIndex.Build) hit the heap; every
+ // build-time buffer here is a pooled ref list. mt: true is required — this runs off the game loop.
+ using var v4 = PooledRefList.Range>.Create(mt: true);
+ using var v6 = PooledRefList.Range>.Create(mt: true);
+
+ var rest = data;
+ while (!rest.IsEmpty)
+ {
+ ReadOnlySpan line;
+ var nl = rest.IndexOf((byte)'\n');
+ if (nl >= 0)
+ {
+ line = rest[..nl];
+ rest = rest[(nl + 1)..];
+ }
+ else
+ {
+ line = rest;
+ rest = default;
+ }
+
+ line = line[Ascii.Trim(line)];
+ if (line.IsEmpty || line[0] == (byte)'#' || line[0] == (byte)';')
+ {
+ continue;
+ }
+
+ var slash = line.IndexOf((byte)'/');
+ var addr = slash >= 0 ? line[..slash] : line;
+ var bitsToken = slash >= 0 ? line[(slash + 1)..] : default;
+
+ if (addr.IndexOf((byte)':') < 0)
+ {
+ // IPv4 single or CIDR — parsed straight from the byte span.
+ if (slash >= 0)
+ {
+ if (IPAddressUtility.TryParseV4(addr, out var ip) &&
+ TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 32)
+ {
+ var size = bits == 0 ? 0xFFFFFFFFu : (1u << (32 - bits)) - 1;
+ var b = ip & ~size;
+ v4.Add(new SortedRangeIndex.Range(b, b + size));
+ parsed++;
+ }
+ else
+ {
+ skipped++;
+ }
+ }
+ else if (IPAddressUtility.TryParseV4(addr, out var ip))
+ {
+ v4.Add(new SortedRangeIndex.Range(ip, ip));
+ parsed++;
+ }
+ else
+ {
+ skipped++;
+ }
+ }
+ else if (TryDecodeV6(addr, out var v))
+ {
+ // IPv6 is rare in these feeds; the single token was decoded and framework-parsed above.
+ if (slash >= 0)
+ {
+ if (TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 128)
+ {
+ var mask = bits == 0 ? UInt128.Zero : ~((UInt128.One << (128 - bits)) - 1);
+ var b = v & mask;
+ v6.Add(new SortedRangeIndex.Range(b, b | ~mask));
+ parsed++;
+ }
+ else
+ {
+ skipped++;
+ }
+ }
+ else
+ {
+ v6.Add(new SortedRangeIndex.Range(v, v));
+ parsed++;
+ }
+ }
+ else
+ {
+ skipped++;
+ }
+ }
+
+ v4.Sort(SortedRangeIndex.ByMin);
+ v6.Sort(SortedRangeIndex.ByMin);
+ return new BlocklistSnapshot(SortedRangeIndex.Build(v4.AsSpan()), SortedRangeIndex.Build(v6.AsSpan()));
+ }
+
+ // Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser.
+ private static bool TryDecodeV6(ReadOnlySpan addr, out UInt128 v)
+ {
+ v = UInt128.Zero;
+ if (addr.Length > 45)
+ {
+ return false;
+ }
+
+ Span chars = stackalloc char[addr.Length];
+ for (var i = 0; i < addr.Length; i++)
+ {
+ chars[i] = (char)addr[i];
+ }
+
+ if (!IPAddress.TryParse(chars, out var a) || a.AddressFamily != AddressFamily.InterNetworkV6)
+ {
+ return false;
+ }
+
+ v = a.ToUInt128();
+ return true;
+ }
+
+ private static bool TryParseBits(ReadOnlySpan token, out int bits)
+ {
+ if (Utf8Parser.TryParse(token, out bits, out var consumed) && consumed == token.Length)
+ {
+ return true;
+ }
+
+ bits = 0;
+ return false;
+ }
+
+ public bool IsBanned(IPAddress ip)
+ {
+ if (ip.IsIPv4MappedToIPv6)
+ {
+ // v6-encoded v4 must not dodge the v4 set; extract the embedded v4 uint directly.
+ return IPAddressUtility.TryMappedV4(ip, out var mv) && _v4.Contains(mv);
+ }
+
+ if (ip.AddressFamily == AddressFamily.InterNetwork)
+ {
+ return IPAddressUtility.TryV4(ip, out var v) && _v4.Contains(v);
+ }
+
+ if (ip.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return _v6.Contains(ip.ToUInt128());
+ }
+
+ return false;
+ }
+}
diff --git a/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs b/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs
new file mode 100644
index 000000000..afb5b3a6d
--- /dev/null
+++ b/Projects/Server/Network/Bans/Blocklist/FileBlocklist.cs
@@ -0,0 +1,155 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: FileBlocklist.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.IO;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Server.Logging;
+
+namespace Server.Network.Bans.Blocklist;
+
+///
+/// In-app gate for a large, file-sourced IP blocklist. Holds an immutable snapshot swapped atomically
+/// by an off-loop reload poll; accept-path reads are lock-free. Inert when no file is configured.
+///
+public static class FileBlocklist
+{
+ private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileBlocklist));
+
+ private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
+ private static string _path;
+ private static TimeSpan _interval;
+ private static string _lastGenerated;
+ private static DateTime _lastWriteUtc;
+ private static CancellationTokenSource _cts;
+
+ public static int Count => _snapshot.Count;
+
+ public static void Configure()
+ {
+ BanConfiguration.Configure();
+ var s = BanConfiguration.Settings;
+ _path = s.BlocklistFile;
+ _interval = s.BlocklistReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.BlocklistReloadInterval;
+ }
+
+ public static void Start(CancellationToken token)
+ {
+ if (string.IsNullOrWhiteSpace(_path))
+ {
+ logger.Information("FileBlocklist disabled (blocklistFile empty in bans.json)");
+ return;
+ }
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ Reload(); // synchronous prime; empty on failure (fail-open)
+ _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
+ }
+
+ public static void Stop()
+ {
+ _cts?.Cancel();
+ _cts?.Dispose();
+ _cts = null;
+ }
+
+ public static bool IsBanned(IPAddress ip) => _snapshot.IsBanned(ip);
+
+ // Test hook: inject a snapshot without file I/O.
+ public static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot;
+
+ private static async ValueTask PollLoop(CancellationToken token)
+ {
+ while (!token.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_interval, token);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ try
+ {
+ if (ChangedSinceLastLoad())
+ {
+ while (World.Saving || World.WorldState == WorldState.PendingSave)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(1), token);
+ }
+
+ Reload();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception e)
+ {
+ logger.Warning(e, "FileBlocklist reload check failed; keeping last snapshot ({Count})", Count);
+ }
+ }
+ }
+
+ private static bool ChangedSinceLastLoad()
+ {
+ try
+ {
+ var info = new FileInfo(_path);
+ if (!info.Exists)
+ {
+ return false;
+ }
+ if (info.LastWriteTimeUtc == _lastWriteUtc)
+ {
+ return false; // cheapest guard
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated;
+ }
+
+ private static void Reload()
+ {
+ // Capture the mtime/header BEFORE Load() so the markers describe the version we're about to
+ // parse, not whatever the producer may have atomically swapped in mid-parse. If a swap happens
+ // mid-parse, the markers describe the old-or-equal version, so the next poll detects the change
+ // and reloads again -- this errs toward reloading and never skips a version.
+ var writeUtc = default(DateTime);
+ try
+ {
+ writeUtc = new FileInfo(_path).LastWriteTimeUtc;
+ }
+ catch
+ {
+ /* keep default */
+ }
+
+ BlocklistFile.TryReadHeader(_path, out var h);
+
+ var next = BlocklistFile.Load(_path, out var parsed, out var skipped);
+ _snapshot = next; // single volatile swap; readers see old or new whole
+ _lastGenerated = h.Generated;
+ _lastWriteUtc = writeUtc;
+ logger.Information("FileBlocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}",
+ parsed, next.Count, skipped, h.Generated);
+ }
+}
diff --git a/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs b/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs
new file mode 100644
index 000000000..baec5dfbf
--- /dev/null
+++ b/Projects/Server/Network/Bans/Blocklist/PromotedGuard.cs
@@ -0,0 +1,55 @@
+/*************************************************************************
+ * ModernUO *
+ * Copyright 2019-2026 - ModernUO Development Team *
+ * Email: hi@modernuo.com *
+ * File: PromotedGuard.cs *
+ * *
+ * This program is free software: you can redistribute it and/or modify *
+ * it under the terms of the GNU General Public License as published by *
+ * the Free Software Foundation, either version 3 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * You should have received a copy of the GNU General Public License *
+ * along with this program. If not, see . *
+ *************************************************************************/
+
+using System;
+using System.Collections.Generic;
+
+namespace Server.Network.Bans.Blocklist;
+
+/// Suppresses re-reporting the same IP within a TTL. Accept-path thread only.
+public sealed class PromotedGuard
+{
+ private readonly Dictionary _expiry = new();
+
+ public bool TryMark(UInt128 ip, long nowTicks, long ttlMs)
+ {
+ if (_expiry.TryGetValue(ip, out var exp) && exp - nowTicks > 0)
+ {
+ return false;
+ }
+ _expiry[ip] = nowTicks + ttlMs;
+ return true;
+ }
+
+ public void Sweep(long nowTicks)
+ {
+ if (_expiry.Count == 0)
+ {
+ return;
+ }
+ using var dead = Collections.PooledRefQueue.Create();
+ foreach (var (ip, exp) in _expiry)
+ {
+ if (exp - nowTicks <= 0)
+ {
+ dead.Enqueue(ip);
+ }
+ }
+ while (dead.Count > 0)
+ {
+ _expiry.Remove(dead.Dequeue());
+ }
+ }
+}