From 858c1d18bcbeda94306b032c24a2f5a4f7e93e9a Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:49:16 -0700 Subject: [PATCH 01/64] fix(opl): only apply the ':#' cliloc marker to integer values (#2540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectPropertyList.AppendFormatted(value, format)` treated **any** `{value:#}` as the cliloc marker (emitting `#`). But cliloc numbers are integers — a `float`/`double`/`decimal` formatted with `#` is the standard custom-numeric (`#` = digit placeholder) format, not a cliloc reference, so those were being mis-marked. Gate the marker on an integer value type: ```csharp if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte) ``` Now `{someFloat:#}` formats normally (passes `#` through to `TryFormat`); the marker/standard-format ambiguity narrows to the harmless `{0:#}` **integer** case (`#0`). Existing `AddLocalized(int)` / `{value:#}` (all `int`) are unaffected. Adds `ObjectPropertyListSpanAddTests.HashFormat_OnlyMarksIntegers`: `int {value:#}` → `#`; `double {value:#}` → `42.0.ToString("#")` (`"42"`, no `#`). --- .../ObjectPropertyListSpanAddTests.cs | 15 +++++++++++++++ .../Server/PropertyList/ObjectPropertyList.cs | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs index 408c53386..97e2d9f58 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs @@ -62,6 +62,21 @@ public class ObjectPropertyListSpanAddTests Assert.Equal((1070722, "Custom"), entries[0]); } + [Fact] + public void HashFormat_OnlyMarksIntegers() + { + // Integer {value:#} emits the cliloc marker "#". + var intList = new ObjectPropertyList(null); + intList.Add(1062028, $"{1043009:#}"); + Assert.Equal((1062028, "#1043009"), Decode(intList)[0]); + + // Float {value:#} is the standard '#' custom-numeric (digit-placeholder) format, not a cliloc + // marker -- so no leading '#'. + var dblList = new ObjectPropertyList(null); + dblList.Add(1062028, $"{42.0:#}"); + Assert.Equal((1062028, 42.0.ToString("#")), Decode(dblList)[0]); // "42" + } + [Fact] public void Add_TruncatesArgumentOverMaxLength() { diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 73288f5e1..6404eccbd 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -384,9 +384,9 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value, string? format) { - // We support localization '#' cliloc formatter for custom property lists - // This allows someone to build an IPropertyList that creates HTML using the same syntax as LocalizationInterpolationHandler - if (format == "#") + // '#' marks an integer argument as a cliloc ("#"). Integers only -- a float/double/decimal + // '#' is the standard numeric format, not a cliloc marker. + if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte) { AppendLiteral("#"); format = null; From d8acde2b0af4fb081d882f60f5b782d04a7ab03f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:35:33 -0700 Subject: [PATCH 02/64] feat(objects): cache DTOs + chunk-key/friendly-type naming helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/ObjectNamingTests.cs | 29 ++++++ .../Commands/Object Creation/ObjectDocs.cs | 93 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectDocs.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs new file mode 100644 index 000000000..f24216df8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs @@ -0,0 +1,29 @@ +using System; +using Server.Commands; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectNamingTests +{ + [Theory] + [InlineData("Items.Skill Items.Magical", "items.skill-items.magical")] + [InlineData("Items.Weapons.Swords", "items.weapons.swords")] + [InlineData("Mobiles.Uncategorized", "mobiles.uncategorized")] + public void ChunkKey_lowercases_and_replaces_spaces(string category, string expected) + { + Assert.Equal(expected, ObjectNaming.ChunkKey(category)); + } + + [Theory] + [InlineData(typeof(int), "int")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(double), "double")] + [InlineData(typeof(int?), "int?")] + [InlineData(typeof(Server.Items.WeaponQuality), "WeaponQuality")] + public void FriendlyTypeName_maps_primitives_and_keeps_enum_names(Type t, string expected) + { + Assert.Equal(expected, ObjectNaming.FriendlyTypeName(t)); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs new file mode 100644 index 000000000..8f18dd8fc --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Server.Commands; + +public static class ObjectNaming +{ + public static string ChunkKey(string category) => category.ToLowerInvariant().Replace(' ', '-'); + + public static string FriendlyTypeName(Type t) + { + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return $"{FriendlyTypeName(Nullable.GetUnderlyingType(t))}?"; + } + + return t switch + { + _ when t == typeof(int) => "int", + _ when t == typeof(uint) => "uint", + _ when t == typeof(bool) => "bool", + _ when t == typeof(string) => "string", + _ when t == typeof(double) => "double", + _ when t == typeof(float) => "float", + _ when t == typeof(long) => "long", + _ when t == typeof(ulong) => "ulong", + _ when t == typeof(short) => "short", + _ when t == typeof(ushort) => "ushort", + _ when t == typeof(byte) => "byte", + _ when t == typeof(sbyte) => "sbyte", + _ when t == typeof(char) => "char", + _ when t == typeof(decimal) => "decimal", + _ => t.Name + }; + } +} + +public sealed class ObjectIndexEntry +{ + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("entity")] public string Entity { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } + [JsonPropertyName("chunk")] public string Chunk { get; set; } + [JsonPropertyName("gfx")] public int ItemID { get; set; } + [JsonPropertyName("hue")] public int Hue { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("cliloc")] public int? Cliloc { get; set; } +} + +public sealed class ObjectIndexFile +{ + [JsonPropertyName("generatedUtc")] public string GeneratedUtc { get; set; } + [JsonPropertyName("objects")] public List Objects { get; set; } = []; +} + +public sealed class ParamDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("default")] public string Default { get; set; } + [JsonPropertyName("isParams")] public bool IsParams { get; set; } +} + +public sealed class CtorDoc +{ + [JsonPropertyName("parameters")] public List Parameters { get; set; } = []; +} + +public sealed class PropertyDoc +{ + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + [JsonPropertyName("readLevel")] public string ReadLevel { get; set; } + [JsonPropertyName("writeLevel")] public string WriteLevel { get; set; } + [JsonPropertyName("readOnly")] public bool ReadOnly { get; set; } + [JsonPropertyName("enumValues")] public string[] EnumValues { get; set; } +} + +public sealed class OplLine +{ + [JsonPropertyName("cliloc")] public int Cliloc { get; set; } + [JsonPropertyName("args")] public string Args { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } +} + +public sealed class ObjectDetail +{ + [JsonPropertyName("baseType")] public string BaseType { get; set; } + [JsonPropertyName("ctors")] public List Ctors { get; set; } = []; + [JsonPropertyName("properties")] public List Properties { get; set; } = []; + [JsonPropertyName("opl")] public List Opl { get; set; } = []; +} From d02cb5687141d2349a3178e1119750b51c6b3e3e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:44:34 -0700 Subject: [PATCH 03/64] =?UTF-8?q?feat(objects):=20ExtractLean=20=E2=80=94?= =?UTF-8?q?=20itemID/hue/name/cliloc=20from=20a=20live=20instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectIntrospectionLeanTests.cs | 26 ++++++++ .../Object Creation/ObjectIntrospection.cs | 60 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs new file mode 100644 index 000000000..935ea3cdb --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs @@ -0,0 +1,26 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionLeanTests +{ + [Fact] + public void ExtractLean_reads_item_id_from_a_weapon() + { + // Katana ctor is base(0x13FF) — era-independent. + var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); + Assert.Equal(0x13FF, lean.ItemID); + } + + [Fact] + public void ExtractLean_reads_hue_and_cliloc_from_a_runebook() + { + // Runebook sets Hue = 0x461 and LabelNumber 1041267 regardless of era. + var lean = ObjectIntrospection.ExtractLean(typeof(Runebook)); + Assert.Equal(0x461, lean.Hue); + Assert.Equal(1041267, lean.Cliloc); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs new file mode 100644 index 000000000..4d7a0222e --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -0,0 +1,60 @@ +using System; +using Server.Items; + +namespace Server.Commands; + +public readonly record struct LeanMetadata(int ItemID, int Hue, string Name, int? Cliloc); + +public static class ObjectIntrospection +{ + public static LeanMetadata ExtractLean(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var itemID = item.ItemID; + if (item is BaseAddon addon && addon.Components.Count == 1) + { + itemID = addon.Components[0].ItemID; + } + + if (itemID > TileData.MaxItemValue) + { + itemID = 1; + } + + var hue = item.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + + var cliloc = item.LabelNumber > 0 ? item.LabelNumber : (int?)null; + var name = item.Name ?? (cliloc.HasValue ? Server.Localization.GetText(cliloc.Value) : null); + + return new LeanMetadata(itemID, hue, name, cliloc); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var itemID = ShrinkTable.Lookup(m, 1); + var hue = m.Hue & 0x7FFF; + hue = (hue & 0x4000) != 0 ? 0 : hue; + return new LeanMetadata(itemID, hue, m.Name, null); + } + finally + { + m.Delete(); + } + } + + throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); + } +} From 95d57afdec3104c02068a29a96033f522c4e8760 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:49:48 -0700 Subject: [PATCH 04/64] =?UTF-8?q?feat(objects):=20ExtractCtors=20=E2=80=94?= =?UTF-8?q?=20constructible=20ctor=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectIntrospectionCtorsTests.cs | 24 ++++++++++++++ .../Object Creation/ObjectIntrospection.cs | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs new file mode 100644 index 000000000..fcdaed07c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs @@ -0,0 +1,24 @@ +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionCtorsTests +{ + [Fact] + public void ExtractCtors_lists_both_constructible_runebook_overloads() + { + // Runebook has [Constructible] Runebook() and [Constructible] Runebook(int maxCharges). + var ctors = ObjectIntrospection.ExtractCtors(typeof(Runebook)); + + Assert.Equal(2, ctors.Count); + Assert.Contains(ctors, c => c.Parameters.Count == 0); + + var parameterized = Assert.Single(ctors, c => c.Parameters.Count == 1); + Assert.Equal("maxCharges", parameterized.Parameters[0].Name); + Assert.Equal("int", parameterized.Parameters[0].Type); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index 4d7a0222e..357909c77 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Reflection; using Server.Items; namespace Server.Commands; @@ -57,4 +59,35 @@ public static class ObjectIntrospection throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); } + + public static List ExtractCtors(Type type) + { + var docs = new List(); + + foreach (var ctor in type.GetConstructors()) + { + if (!Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + continue; + } + + var doc = new CtorDoc(); + foreach (var p in ctor.GetParameters()) + { + doc.Parameters.Add( + new ParamDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(p.ParameterType), + Default = p.HasDefaultValue ? p.DefaultValue?.ToString() ?? "null" : null, + IsParams = p.IsDefined(typeof(ParamArrayAttribute), false) + } + ); + } + + docs.Add(doc); + } + + return docs; + } } From 685fbc146edecd6dbdf19686d9684f167310236f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:54:40 -0700 Subject: [PATCH 05/64] =?UTF-8?q?feat(objects):=20ExtractProperties=20?= =?UTF-8?q?=E2=80=94=20[CommandProperty]=20props=20with=20enum=20expansion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ObjectIntrospection.ExtractProperties(Type) to extract public instance properties carrying [CommandProperty] attribute, including inherited ones. Properties include type via ObjectNaming.FriendlyTypeName, read/write access levels, readOnly flag, and enum values expanded via Enum.GetNames. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ObjectIntrospectionPropertiesTests.cs | 23 ++++++++++++++ .../Object Creation/ObjectIntrospection.cs | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs new file mode 100644 index 000000000..efda9a929 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs @@ -0,0 +1,23 @@ +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionPropertiesTests +{ + [Fact] + public void ExtractProperties_includes_inherited_item_command_properties() + { + var props = ObjectIntrospection.ExtractProperties(typeof(Runebook)); + + var hue = Assert.Single(props, p => p.Name == "Hue"); + Assert.Equal("int", hue.Type); + + var lootType = Assert.Single(props, p => p.Name == "LootType"); + Assert.NotNull(lootType.EnumValues); + Assert.Contains("Blessed", lootType.EnumValues); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index 357909c77..f264a76f0 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -90,4 +90,34 @@ public static class ObjectIntrospection return docs; } + + public static List ExtractProperties(Type type) + { + var docs = new List(); + + var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + foreach (var p in props) + { + var attr = p.GetCustomAttribute(true); + if (attr == null) + { + continue; + } + + var pt = p.PropertyType; + docs.Add( + new PropertyDoc + { + Name = p.Name, + Type = ObjectNaming.FriendlyTypeName(pt), + ReadLevel = attr.ReadLevel.ToString(), + WriteLevel = attr.WriteLevel.ToString(), + ReadOnly = attr.ReadOnly || !p.CanWrite, + EnumValues = pt.IsEnum ? Enum.GetNames(pt) : null + } + ); + } + + return docs; + } } From c71bc4bcd5848b9a7936d358362969510ae8d356 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:59:37 -0700 Subject: [PATCH 06/64] =?UTF-8?q?feat(objects):=20ExtractOpl=20=E2=80=94?= =?UTF-8?q?=20decode=20OPL=20buffer=20into=20cliloc/args/text=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectIntrospectionOplTests.cs | 17 +++++ .../Object Creation/ObjectIntrospection.cs | 64 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs new file mode 100644 index 000000000..b360a2726 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs @@ -0,0 +1,17 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectIntrospectionOplTests +{ + [Fact] + public void ExtractOpl_captures_the_runebook_name_cliloc() + { + // Runebook.LabelNumber is 1041267 ("runebook") — it appears as an OPL line. + var opl = ObjectIntrospection.ExtractOpl(typeof(Runebook)); + Assert.Contains(opl, line => line.Cliloc == 1041267); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index f264a76f0..f4d076f97 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -1,6 +1,8 @@ using System; +using System.Buffers.Binary; using System.Collections.Generic; using System.Reflection; +using System.Text; using Server.Items; namespace Server.Commands; @@ -120,4 +122,66 @@ public static class ObjectIntrospection return docs; } + + public static List ExtractOpl(Type type) + { + if (type.IsAssignableTo(typeof(Item))) + { + var item = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(item); + item.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + item.Delete(); + } + } + + if (type.IsAssignableTo(typeof(Mobile))) + { + var m = type.CreateInstance(); + try + { + var opl = new ObjectPropertyList(m); + m.GetProperties(opl); + return DecodeOpl(opl); + } + finally + { + m.Delete(); + } + } + + return []; + } + + private static List DecodeOpl(ObjectPropertyList opl) + { + opl.Terminate(); + var buffer = opl.Buffer; + var lines = new List(); + var pos = 15; // fixed OPL header length + + while (pos + 4 <= buffer.Length) + { + var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); + pos += 4; + if (cliloc == 0) + { + break; + } + + var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); + pos += 2; + var args = byteLen > 0 ? Encoding.Unicode.GetString(buffer, pos, byteLen) : null; + pos += byteLen; + + lines.Add(new OplLine { Cliloc = cliloc, Args = args, Text = Localization.GetText(cliloc) }); + } + + return lines; + } } From ee0c8c256db4887fabe654aa4a1de7394ba47f59 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:05:15 -0700 Subject: [PATCH 07/64] =?UTF-8?q?feat(objects):=20DiscoverConstructibleTyp?= =?UTF-8?q?es=20=E2=80=94=20all=20constructible=20Item/Mobile=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement DiscoverConstructibleTypes() method with HasConstructibleCtor helper to enumerate every non-abstract Item/Mobile subclass in AssemblyHandler.Assemblies that has at least one [Constructible] ctor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/ObjectDiscoveryTests.cs | 19 +++++++++ .../Object Creation/ObjectIntrospection.cs | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs new file mode 100644 index 000000000..d36f8bb8a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs @@ -0,0 +1,19 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectDiscoveryTests +{ + [Fact] + public void Discover_includes_concrete_constructibles_and_excludes_abstract() + { + var types = ObjectIntrospection.DiscoverConstructibleTypes(); + + Assert.Contains(typeof(Katana), types); + Assert.Contains(typeof(Runebook), types); + Assert.DoesNotContain(typeof(BaseWeapon), types); // abstract + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs index f4d076f97..f0a11b31c 100644 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs @@ -184,4 +184,45 @@ public static class ObjectIntrospection return lines; } + + public static List DiscoverConstructibleTypes() + { + var results = new List(); + + foreach (var asm in AssemblyHandler.Assemblies) + { + foreach (var type in AssemblyHandler.GetTypeCache(asm).Types) + { + if (type.IsAbstract) + { + continue; + } + + if (!typeof(Item).IsAssignableFrom(type) && !typeof(Mobile).IsAssignableFrom(type)) + { + continue; + } + + if (HasConstructibleCtor(type)) + { + results.Add(type); + } + } + } + + return results; + } + + private static bool HasConstructibleCtor(Type type) + { + foreach (var ctor in type.GetConstructors()) + { + if (Attributes.IsConstructible(ctor, AccessLevel.Developer)) + { + return true; + } + } + + return false; + } } From 8f20106aecdd1df4679b67b3d962de94fdb626c0 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:08:57 -0700 Subject: [PATCH 08/64] =?UTF-8?q?feat(objects):=20CategorizationSync.Recon?= =?UTF-8?q?cile=20=E2=80=94=20append=20Uncategorized=20+=20report=20orphan?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/CategorizationSyncTests.cs | 44 +++++++++++ .../Object Creation/CategorizationSync.cs | 77 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/CategorizationSync.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs new file mode 100644 index 000000000..8fb4a225d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class CategorizationSyncTests +{ + private static CAGJson Cat(string category, params System.Type[] types) => + new() + { + Category = category, + Objects = types.Select(t => new CAGObject { Type = t }).ToArray() + }; + + [Fact] + public void Reconcile_appends_missing_types_to_uncategorized() + { + var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Katana), typeof(Runebook) }; + + var (updated, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Runebook", report.Appended); + Assert.Empty(report.Orphaned); + + var uncategorized = Assert.Single(updated, c => c.Category == "Items.Uncategorized"); + Assert.Contains(uncategorized.Objects, o => o.Type == typeof(Runebook)); + } + + [Fact] + public void Reconcile_reports_orphans_not_in_discovered() + { + var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; + var discovered = new List { typeof(Runebook) }; + + var (_, report) = CategorizationSync.Reconcile(categorization, discovered); + + Assert.Contains("Katana", report.Orphaned); + Assert.Contains("Runebook", report.Appended); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs new file mode 100644 index 000000000..90b8f0169 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server.Items; + +namespace Server.Commands; + +public sealed record SyncReport(List Appended, List Orphaned); + +public static class CategorizationSync +{ + public static (List updated, SyncReport report) Reconcile( + List categorization, IReadOnlyList discovered + ) + { + var categorizedTypes = new HashSet(); + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categorizedTypes.Add(obj.Type); + } + } + } + + var discoveredSet = new HashSet(discovered); + + var orphaned = categorizedTypes + .Where(t => !discoveredSet.Contains(t)) + .Select(t => t.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + var updated = new List(categorization); + var appended = new List(); + var itemAppend = new List(); + var mobileAppend = new List(); + + foreach (var type in discovered) + { + if (categorizedTypes.Contains(type)) + { + continue; + } + + appended.Add(type.Name); + var target = typeof(Mobile).IsAssignableFrom(type) ? mobileAppend : itemAppend; + target.Add(new CAGObject { Type = type }); + } + + AppendUncategorized(updated, "Items.Uncategorized", itemAppend); + AppendUncategorized(updated, "Mobiles.Uncategorized", mobileAppend); + + return (updated, new SyncReport(appended, orphaned)); + } + + private static void AppendUncategorized(List updated, string category, List toAdd) + { + if (toAdd.Count == 0) + { + return; + } + + var existing = updated.FirstOrDefault(c => c.Category == category); + if (existing == null) + { + updated.Add(new CAGJson { Category = category, Objects = toAdd.ToArray() }); + return; + } + + var merged = new List(existing.Objects ?? []); + merged.AddRange(toAdd); + updated[updated.IndexOf(existing)] = existing with { Objects = merged.ToArray() }; + } +} From a55d83fc215352386d126ca45aa0bbd3be7ce819 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:13:36 -0700 Subject: [PATCH 09/64] =?UTF-8?q?feat(objects):=20ObjectCacheBuilder=20?= =?UTF-8?q?=E2=80=94=20assemble=20index=20+=20per-category=20detail=20chun?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectCacheBuilderTests.cs | 35 +++++++++++ .../Object Creation/ObjectCacheBuilder.cs | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs new file mode 100644 index 000000000..860822597 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectCacheBuilderTests +{ + [Fact] + public void Build_produces_index_row_and_detail_chunk() + { + var extracted = new ExtractedObject( + typeof(Runebook), + "item", + "Items.Skill Items.Magical", + new LeanMetadata(8901, 0x461, "runebook", 1041267), + [new CtorDoc()], + [new PropertyDoc { Name = "Hue", Type = "int" }], + [new OplLine { Cliloc = 1041267 }], + "Item" + ); + + var (index, chunks) = ObjectCacheBuilder.Build([extracted], "2026-07-19T00:00:00Z"); + + var row = Assert.Single(index.Objects); + Assert.Equal("Runebook", row.Type); + Assert.Equal("items.skill-items.magical", row.Chunk); + Assert.Equal(8901, row.ItemID); + + var chunk = Assert.Contains("items.skill-items.magical", chunks); + Assert.Contains("Runebook", chunk.Keys); + Assert.Equal("Item", chunk["Runebook"].BaseType); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs new file mode 100644 index 000000000..0ce79145f --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Server.Commands; + +public sealed record ExtractedObject( + Type Type, + string Entity, + string Category, + LeanMetadata Lean, + List Ctors, + List Properties, + List Opl, + string BaseType +); + +public static class ObjectCacheBuilder +{ + public static (ObjectIndexFile index, Dictionary> chunks) Build( + IReadOnlyList objects, string generatedUtc + ) + { + var index = new ObjectIndexFile { GeneratedUtc = generatedUtc }; + var chunks = new Dictionary>(); + + foreach (var obj in objects) + { + var chunkKey = ObjectNaming.ChunkKey(obj.Category); + + index.Objects.Add( + new ObjectIndexEntry + { + Type = obj.Type.Name, + Entity = obj.Entity, + Category = obj.Category, + Chunk = chunkKey, + ItemID = obj.Lean.ItemID, + Hue = obj.Lean.Hue, + Name = obj.Lean.Name, + Cliloc = obj.Lean.Cliloc + } + ); + + if (!chunks.TryGetValue(chunkKey, out var chunk)) + { + chunks[chunkKey] = chunk = new Dictionary(); + } + + chunk[obj.Type.Name] = new ObjectDetail + { + BaseType = obj.BaseType, + Ctors = obj.Ctors, + Properties = obj.Properties, + Opl = obj.Opl + }; + } + + return (index, chunks); + } +} From 0ac505d7b68677256431d05bbf27b241e73501d9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:21:12 -0700 Subject: [PATCH 10/64] feat(objects): ObjectCacheGenerator core + GenObjects command Wraps introspection, categorization sync, and cache-building into a single file-I/O-free Generate() so the full pipeline is testable without a shard; GenObjects is a thin command that adds file I/O and operator messaging on top. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Objects/ObjectCacheGeneratorTests.cs | 32 +++++++++ Projects/UOContent/Commands/GenObjects.cs | 56 ++++++++++++++++ .../Object Creation/ObjectCacheGenerator.cs | 67 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs create mode 100644 Projects/UOContent/Commands/GenObjects.cs create mode 100644 Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs new file mode 100644 index 000000000..96521bdfd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class ObjectCacheGeneratorTests +{ + [Fact] + public void Generate_builds_index_and_chunks_from_extracted_objects() + { + var discovered = new List { typeof(Runebook), typeof(Katana) }; + var categorization = new List(); // empty -> both land in Items.Uncategorized + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + Assert.Equal(2, result.Index.Objects.Count); + Assert.NotEmpty(result.Chunks); + + var runebook = Assert.Single(result.Index.Objects, o => o.Type == "Runebook"); + Assert.True(runebook.ItemID > 0); + Assert.Equal(1041267, runebook.Cliloc); + Assert.Equal("items.uncategorized", runebook.Chunk); + + Assert.Contains("Runebook", result.Report.Appended); + Assert.Contains("items.uncategorized", result.Chunks.Keys); + Assert.Contains("Runebook", result.Chunks["items.uncategorized"].Keys); + } +} diff --git a/Projects/UOContent/Commands/GenObjects.cs b/Projects/UOContent/Commands/GenObjects.cs new file mode 100644 index 000000000..dfd6903a1 --- /dev/null +++ b/Projects/UOContent/Commands/GenObjects.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.IO; +using Server.Json; +using Server.Logging; + +namespace Server.Commands; + +public static class GenObjects +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenObjects)); + + public static void Configure() + { + CommandSystem.Register("GenObjects", AccessLevel.Developer, GenObjects_OnCommand); + } + + [Usage("GenObjects")] + [Aliases("GenObjWeb")] + [Description("Generates the objects cache (index + detail chunks) and syncs categorization.json.")] + private static void GenObjects_OnCommand(CommandEventArgs e) + { + var baseDir = Core.BaseDirectory; + var categorizationPath = Path.Combine(baseDir, "Data", "categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath) ?? []; + var discovered = ObjectIntrospection.DiscoverConstructibleTypes(); + + var result = ObjectCacheGenerator.Generate(categorization, discovered); + + var objectsDir = Path.Combine(baseDir, "Data", "objects"); + var detailDir = Path.Combine(objectsDir, "detail"); + Directory.CreateDirectory(detailDir); + + JsonConfig.Serialize(Path.Combine(objectsDir, "index.json"), result.Index); + foreach (var (chunkKey, map) in result.Chunks) + { + JsonConfig.Serialize(Path.Combine(detailDir, $"{chunkKey}.json"), map); + } + + JsonConfig.Serialize(categorizationPath, result.UpdatedCategorization); + + e.Mobile.SendMessage( + $"Objects cache written: {result.Index.Objects.Count} objects, {result.Chunks.Count} chunks. " + + $"Appended {result.Report.Appended.Count} to Uncategorized, {result.Report.Orphaned.Count} orphaned." + ); + + if (result.Report.Appended.Count > 0) + { + logger.Information("Appended to Uncategorized: {Types}", string.Join(", ", result.Report.Appended)); + } + + if (result.Report.Orphaned.Count > 0) + { + logger.Warning("Orphaned categorization entries: {Types}", string.Join(", ", result.Report.Orphaned)); + } + } +} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs new file mode 100644 index 000000000..1f7db92b6 --- /dev/null +++ b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Commands; + +public sealed record ObjectCacheResult( + ObjectIndexFile Index, + Dictionary> Chunks, + List UpdatedCategorization, + SyncReport Report +); + +public static class ObjectCacheGenerator +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectCacheGenerator)); + + public static ObjectCacheResult Generate(List categorization, IReadOnlyList discovered) + { + var (updatedCategorization, report) = CategorizationSync.Reconcile(categorization, discovered); + + var categoryByType = new Dictionary(); + foreach (var cag in updatedCategorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type != null) + { + categoryByType[obj.Type] = cag.Category; + } + } + } + + var generatedUtc = DateTime.UtcNow.ToString("O"); + var extracted = new List(); + foreach (var type in discovered) + { + try + { + var entity = typeof(Mobile).IsAssignableFrom(type) ? "mobile" : "item"; + var category = categoryByType.TryGetValue(type, out var c) + ? c + : entity == "mobile" ? "Mobiles.Uncategorized" : "Items.Uncategorized"; + + extracted.Add( + new ExtractedObject( + type, + entity, + category, + ObjectIntrospection.ExtractLean(type), + ObjectIntrospection.ExtractCtors(type), + ObjectIntrospection.ExtractProperties(type), + ObjectIntrospection.ExtractOpl(type), + type.BaseType?.Name ?? "object" + ) + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed to introspect {Type}; skipping.", type); + } + } + + var (index, chunks) = ObjectCacheBuilder.Build(extracted, generatedUtc); + return new ObjectCacheResult(index, chunks, updatedCategorization, report); + } +} From 06001a6ea57ff05bd2f7b6c43bd347a48049bb5b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:27:46 -0700 Subject: [PATCH 11/64] refac(objects): CAGLoader consumes index.json cache with live fallback CAGLoader.Load() now reads Data/objects/index.json (BuildTree) and only falls back to live type instantiation for entries missing from the cache (stale-cache warning) or when the index file itself is absent (LoadLegacy, the original categorization.json live-load, moved verbatim). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/CagTreeBuilderTests.cs | 48 ++++++ .../Commands/Object Creation/CAGLoader.cs | 145 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs new file mode 100644 index 000000000..0d61e34d8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -0,0 +1,48 @@ +using Server.Commands; +using Server.Items; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +[Collection("Sequential UOContent Tests")] +public class CagTreeBuilderTests +{ + [Fact] + public void BuildTree_creates_nested_categories_and_resolves_types() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 + } + ] + }; + + var root = CAGLoader.BuildTree(index); + + var items = Assert.IsType(FindChild(root, "Items")); + var weapons = Assert.IsType(FindChild(items, "Weapons")); + var swords = Assert.IsType(FindChild(weapons, "Swords")); + + var leaf = Assert.IsType(swords.Nodes[0]); + Assert.Equal(typeof(Katana), leaf.Type); + Assert.Equal(0x13FF, leaf.ItemID); + } + + private static CAGNode FindChild(CAGCategory parent, string title) + { + foreach (var node in parent.Nodes) + { + if (node.Title == title) + { + return node; + } + } + + throw new Xunit.Sdk.XunitException($"No child '{title}'."); + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index cbded692c..f214ee063 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -29,6 +29,151 @@ public static class CAGLoader private static readonly ILogger logger = LogFactory.GetLogger(typeof(CAGLoader)); public static CAGCategory Load() + { + var indexPath = Path.Combine(Core.BaseDirectory, "Data/objects/index.json"); + + if (!File.Exists(indexPath)) + { + logger.Warning("objects/index.json missing — run [GenObjects. Falling back to live categorization load."); + return LoadLegacy(); + } + + var index = JsonConfig.Deserialize(indexPath); + if (index?.Objects == null) + { + throw new JsonException($"Failed to deserialize {indexPath}."); + } + + var root = BuildTree(index); + AddFallbackForStaleCache(root, index); + return root; + } + + public static CAGCategory BuildTree(ObjectIndexFile index) + { + var root = new CAGCategory("Add Menu"); + + foreach (var entry in index.Objects) + { + var type = AssemblyHandler.FindTypeByName(entry.Type); + if (type == null) + { + logger.Warning("Cached type {Type} no longer resolves; skipping.", entry.Type); + continue; + } + + var category = NavigateToCategory(root, entry.Category); + AppendObject( + category, + new CAGObject + { + Type = type, + ItemID = entry.ItemID, + Hue = entry.Hue == 0 ? null : entry.Hue, + Parent = category + } + ); + } + + return root; + } + + private static CAGCategory NavigateToCategory(CAGCategory root, string dotted) + { + var parent = root; + foreach (var name in dotted.Split('.')) + { + var child = FindCategory(parent, name); + if (child == null) + { + child = new CAGCategory(name, parent); + AppendNode(parent, child); + } + + parent = child; + } + + return parent; + } + + private static CAGCategory FindCategory(CAGCategory parent, string title) + { + if (parent.Nodes == null) + { + return null; + } + + foreach (var node in parent.Nodes) + { + if (node is CAGCategory cat && cat.Title == title) + { + return cat; + } + } + + return null; + } + + private static void AppendNode(CAGCategory parent, CAGNode node) + { + var nodes = parent.Nodes ?? []; + var grown = new CAGNode[nodes.Length + 1]; + Array.Copy(nodes, grown, nodes.Length); + grown[^1] = node; + parent.Nodes = grown; + } + + private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj); + + private static void AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index) + { + var cached = new HashSet(); + foreach (var entry in index.Objects) + { + cached.Add(entry.Type); + } + + var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); + var categorization = JsonConfig.Deserialize>(categorizationPath); + if (categorization == null) + { + return; + } + + foreach (var cag in categorization) + { + foreach (var obj in cag.Objects ?? []) + { + if (obj.Type == null || cached.Contains(obj.Type.Name)) + { + continue; + } + + logger.Warning("objects cache stale for {Type} — run [GenObjects.", obj.Type.Name); + try + { + var lean = ObjectIntrospection.ExtractLean(obj.Type); + var category = NavigateToCategory(root, cag.Category); + AppendObject( + category, + new CAGObject + { + Type = obj.Type, + ItemID = lean.ItemID, + Hue = lean.Hue == 0 ? null : lean.Hue, + Parent = category + } + ); + } + catch (Exception ex) + { + logger.Warning(ex, "Failed live fallback for {Type}.", obj.Type.Name); + } + } + } + } + + private static CAGCategory LoadLegacy() { var root = new CAGCategory("Add Menu"); var path = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); From d72313b76bb47488ba1ec1ce0992fcbcba83eba7 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:33:19 -0700 Subject: [PATCH 12/64] style(objects): drop unused System.Linq imports in extraction tests Assert.Single(collection, predicate) is an xUnit overload, not LINQ. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs | 1 - .../Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs index fcdaed07c..6c35df2f8 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Server.Commands; using Server.Items; using Xunit; diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs index efda9a929..1eba2a5bb 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs @@ -1,4 +1,3 @@ -using System.Linq; using Server.Commands; using Server.Items; using Xunit; From bff08c69d212f29c317d41c5c2226972b1764de2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:44:30 -0700 Subject: [PATCH 13/64] perf(objects): batch CAGLoader.BuildTree leaf insertion to O(N) BuildTree previously grew each category's Nodes array by one element per object via AppendObject/AppendNode, causing O(N^2) array copies across large categories (e.g. Items.Uncategorized) on every server startup. Leaves are now accumulated per-category in a List and flushed with a single array allocation per category via a new AppendNodes helper. AddFallbackForStaleCache and LoadLegacy are untouched. Also adds a JSON round-trip test for ObjectIndexFile/ObjectIndexEntry to guard the gfx/hue property mapping and the Objects = [] initializer against double-appending on deserialize. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Commands/Objects/CagTreeBuilderTests.cs | 32 ++++++++++++ .../Objects/ObjectIndexSerializationTests.cs | 52 +++++++++++++++++++ .../Commands/Object Creation/CAGLoader.cs | 28 +++++++++- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs index 0d61e34d8..4716dd239 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using Server.Commands; using Server.Items; using Xunit; @@ -33,6 +34,26 @@ public class CagTreeBuilderTests Assert.Equal(0x13FF, leaf.ItemID); } + [Fact] + public void BuildTree_keeps_multiple_objects_in_one_category() + { + var index = new ObjectIndexFile + { + Objects = + [ + new ObjectIndexEntry { Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 }, + new ObjectIndexEntry { Type = "Longsword", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x0F5E, Hue = 0 } + ] + }; + + var root = CAGLoader.BuildTree(index); + var swords = (CAGCategory)FindNestedCategory(root, "Items", "Weapons", "Swords"); + + var leafTypes = swords.Nodes.OfType().Select(o => o.Type).ToList(); + Assert.Contains(typeof(Katana), leafTypes); + Assert.Contains(typeof(Server.Items.Longsword), leafTypes); + } + private static CAGNode FindChild(CAGCategory parent, string title) { foreach (var node in parent.Nodes) @@ -45,4 +66,15 @@ public class CagTreeBuilderTests throw new Xunit.Sdk.XunitException($"No child '{title}'."); } + + private static CAGNode FindNestedCategory(CAGCategory root, params string[] titles) + { + CAGNode current = root; + foreach (var title in titles) + { + current = FindChild((CAGCategory)current, title); + } + + return current; + } } diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs new file mode 100644 index 000000000..268ee13e0 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs @@ -0,0 +1,52 @@ +using System.IO; +using Server.Commands; +using Server.Json; +using Xunit; + +namespace UOContent.Tests.Commands.Objects; + +public class ObjectIndexSerializationTests +{ + [Fact] + public void ObjectIndexFile_round_trips_through_json() + { + var index = new ObjectIndexFile + { + GeneratedUtc = "2026-07-19T00:00:00Z", + Objects = + [ + new ObjectIndexEntry + { + Type = "Katana", + Entity = "item", + Category = "Items.Weapons.Swords", + Chunk = "items.weapons.swords", + ItemID = 8901, + Hue = 0x461, + Name = "katana", + Cliloc = 1041267 + } + ] + }; + + var tempPath = Path.GetTempFileName(); + try + { + JsonConfig.Serialize(tempPath, index); + var roundTripped = JsonConfig.Deserialize(tempPath); + + Assert.NotNull(roundTripped); + var entry = Assert.Single(roundTripped.Objects); + + Assert.Equal("Katana", entry.Type); + Assert.Equal("items.weapons.swords", entry.Chunk); + Assert.Equal(8901, entry.ItemID); + Assert.Equal(0x461, entry.Hue); + Assert.Equal(1041267, entry.Cliloc); + } + finally + { + File.Delete(tempPath); + } + } +} diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index f214ee063..6b9c37c8b 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -52,6 +52,7 @@ public static class CAGLoader public static CAGCategory BuildTree(ObjectIndexFile index) { var root = new CAGCategory("Add Menu"); + var pending = new Dictionary>(); foreach (var entry in index.Objects) { @@ -63,8 +64,13 @@ public static class CAGLoader } var category = NavigateToCategory(root, entry.Category); - AppendObject( - category, + if (!pending.TryGetValue(category, out var objects)) + { + objects = new List(); + pending[category] = objects; + } + + objects.Add( new CAGObject { Type = type, @@ -75,6 +81,11 @@ public static class CAGLoader ); } + foreach (var (category, objects) in pending) + { + AppendNodes(category, objects); + } + return root; } @@ -125,6 +136,19 @@ public static class CAGLoader private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj); + private static void AppendNodes(CAGCategory parent, IReadOnlyList nodes) + { + var existing = parent.Nodes ?? []; + var grown = new CAGNode[existing.Length + nodes.Count]; + Array.Copy(existing, grown, existing.Length); + for (var i = 0; i < nodes.Count; i++) + { + grown[existing.Length + i] = nodes[i]; + } + + parent.Nodes = grown; + } + private static void AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index) { var cached = new HashSet(); From 1b8aff6b719c66c2f1695069c4ee246044ea82d2 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:15:38 -0700 Subject: [PATCH 14/64] test(objects): skip weapon-itemID assertion when TileData is absent ExtractLean clamps itemID > TileData.MaxItemValue to 1; MaxItemValue is 0 when tiledata.mul is missing (CI), so Katana's 0x13FF only survives with client data. Guard with [SkippableFact] + TileDataRequirement.SkipIfMissing() like other data-dependent tests. The hue/cliloc assertion needs no data and stays a [Fact]. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs index 935ea3cdb..5ddb65278 100644 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs +++ b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs @@ -1,5 +1,6 @@ using Server.Commands; using Server.Items; +using Server.Tests; using Xunit; namespace UOContent.Tests.Commands.Objects; @@ -7,9 +8,13 @@ namespace UOContent.Tests.Commands.Objects; [Collection("Sequential UOContent Tests")] public class ObjectIntrospectionLeanTests { - [Fact] + [SkippableFact] public void ExtractLean_reads_item_id_from_a_weapon() { + // Requires client TileData: ExtractLean clamps itemID > TileData.MaxItemValue to 1, and + // MaxItemValue is 0 when tiledata.mul is absent (CI), so the real 0x13FF only survives with data. + TileDataRequirement.SkipIfMissing(); + // Katana ctor is base(0x13FF) — era-independent. var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); Assert.Equal(0x13FF, lean.ItemID); From 1e97ed50f61fe89d112796ee2d929a8a7512a7ae Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:51:06 -0700 Subject: [PATCH 15/64] fix: Harden Advanced Search: crash-safety, autosave, correct results & worker fixes (#2543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Hardens the **Advanced Search** engine (`Projects/UOContent/Engines/Advanced Search/`) — the GM entity finder that fans searches across background worker threads. A code review surfaced 14 defects (A–N), including a shard-crasher reachable from a single admin typo and a path that silently disables autosave for the rest of the shard's uptime. Each behavioral fix ships with a test. Full `UOContent.Tests` suite: **530/530 green** (21 new AdvancedSearch tests). ## Fixes ### Crash / data-loss - **A — Shard crash on a malformed Property Test.** `AdvancedSearchThreadWorker.Execute` had no `try/catch` and the worker `Thread` is foreground, so a parse throw (`Hits>abc`, `Layer=onehanded` — `Enum.Parse` was case-sensitive, `Hits>1@` — empty sub-expression indexing) terminated the process. Now: `ParseValue`/`CompareValues` use `TryParse`/`Enum.TryParse(ignoreCase)` and return no-match instead of throwing; the per-entity filter is wrapped in `try/catch` (logs + skips); empty expressions are guarded. - **C — Overlapping searches corrupt state + brick autosave.** `_threadWorkers`/`_threadId` were `static` but `DoSearch` is an instance method; a second search (double-click / two admins) stomped shared worker state and could leave a drain waiting forever on the shared `AutoResetEvent`, so `AutoSave.SavesEnabled` was never restored. Now: an `Interlocked` re-entrancy guard rejects concurrent searches. - **G — Autosave restore not guaranteed.** The restore lived only in the success callback. Now it's in a `finally` (plus an outer `catch` covering the synchronous setup and a `catch` on the drain body), so autosave + the guard are always released. ### Wrong results - **D — `@`/`|` operator precedence.** `a@b|c` evaluated as `a && (b || c)` instead of `(a && b) || c`. OR now binds looser than AND (`AdvancedSearchUtilities.EvaluateBoolean`, unit-tested). - **E — Descending sort, partial last page rendered blank** (the index decreased in descending mode and the `break` early-out killed the loop). Now a bounded `VisibleCount`-driven loop renders the last page in both directions. - **F — Deleted entities** were not skipped (ghost rows). Now `DoEntitySearch` skips `entity.Deleted`. - **N — Reference-type comparisons** threw (`Comparer.Default.Compare` on non-`IComparable`) and compared references to a string. Now equality is by value and ordering is guarded to `IComparable` (no throw). ### Worker perf / hardening - **H** busy-spin → `Thread.Yield()` in the drain; **I** `GetProperties()` cached per `Type`; **J** `HandleValidInternal` moved behind the cheap map/range/region filters; **K** worker threads are `IsBackground` + `Exit()` tolerates an already-terminated worker; **L** `_filter == null` guard; **M** consistent `Volatile` access on `_pause`/`_exit`. ### Documented - **B** — the residual worker/event-loop read race is documented on `AdvancedSearchThreadWorker`: workers read live entity state concurrently with the loop, so value-type reads may be stale-but-safe and getter exceptions are swallowed; fully eliminating it would require snapshotting entity fields on the main thread (deferred). ## Notes - New test-only seams (`TryBeginSearch`/`EndSearch`/`IsSearchInProgress`/`VisibleCount`/`TryParseValue`/`EvaluateBoolean`) are `internal` via the existing `InternalsVisibleTo("UOContent.Tests")`. - Dead `public ParseValue` removed. - `ConcurrentDictionary` for the reflection cache is intentional — these workers are genuinely parallel. --- .../RawInterpolatedStringHandlerTests.cs | 1 + .../AdvancedSearchPagingTests.cs | 17 ++ .../AdvancedSearchTypesTests.cs | 27 ++ .../AdvancedSearchUtilitiesTests.cs | 119 +++++++++ .../AdvancedSearchWorkerTests.cs | 95 +++++++ .../Tests/Utilities/TryParseTests.cs | 26 ++ .../Advanced Search/AdvancedSearchGump.cs | 166 ++++++++----- .../AdvancedSearchThreadWorker.cs | 94 +++++-- .../AdvancedSearchUtilities.cs | 231 +++++++++++++----- .../Engines/Factions/Core/Faction.cs | 32 ++- .../UOContent/Engines/Factions/Core/Town.cs | 33 ++- Projects/UOContent/Utilities/Types.cs | 150 ++++++++---- .../01-foundation-changes.md | 60 +++++ 13 files changed, 842 insertions(+), 209 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs diff --git a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs index 0b76d3f94..d4777b758 100644 --- a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace Server.Tests.Buffers; +[Collection("Sequential Server Tests")] public class RawInterpolatedStringHandlerTests { [Fact] diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs new file mode 100644 index 000000000..5166124e5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs @@ -0,0 +1,17 @@ +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +public class AdvancedSearchPagingTests +{ + [Theory] + [InlineData(20, 0, 18, 18)] // full first page + [InlineData(20, 18, 18, 2)] // partial last page -> 2 visible (bug rendered 0 in descending) + [InlineData(5, 0, 18, 5)] + [InlineData(0, 0, 18, 0)] + public void VisibleCount_IsCorrect(int total, int from, int max, int expected) + { + Assert.Equal(expected, AdvancedSearchGump.VisibleCount(total, from, max)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs new file mode 100644 index 000000000..ea14f3cf2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -0,0 +1,27 @@ +using Server; +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class AdvancedSearchTypesTests +{ + [Fact] + public void CompareValues_Poison_ReferenceTypeParsedViaTypes() + { + PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set + + // Poison is a reference type implementing ISpanParsable; it can't use the compile-time span + // path and routes through the shared Server.Types converter. Poison.Parse returns the + // registered singleton, so "= Lethal" is a reference-equality match — this is the case that + // previously compared a Poison against the raw string and always failed. + var prop = Poison.Lethal; + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "=")); + + var ex = Record.Exception(() => + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "="))); + Assert.Null(ex); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs new file mode 100644 index 000000000..9176fae4a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs @@ -0,0 +1,119 @@ +using System; +using Server; +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +public class AdvancedSearchUtilitiesTests +{ + [Theory] + [InlineData("abc")] // not a number -> was FormatException + [InlineData("99999999999")] // overflows int -> was OverflowException + [InlineData("0xZZ")] // bad hex -> was FormatException + public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value) + { + var ex = Record.Exception(() => + { + var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">"); + Assert.False(result); + }); + Assert.Null(ex); + } + + [Theory] + [InlineData("Bogus")] // not a member -> was ArgumentException + [InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException + public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value) + { + var ex = Record.Exception(() => + { + var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "="); + Assert.False(result); + }); + Assert.Null(ex); + } + + [Fact] + public void CompareValues_ValidEnum_IgnoresCase() + { + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "=")); + } + + [Theory] + // leaf value is "T"/"F"; evalLeaf returns leaf=="T" + [InlineData("T", true)] + [InlineData("F", false)] + [InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F) + [InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F) + [InlineData("T@F", false)] + [InlineData("T@T", true)] + [InlineData("F|F", false)] + public void EvaluateBoolean_Precedence(string expr, bool expected) + { + // State is unused here; the leaf evaluator just checks the span equals "T". + var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T")); + Assert.Equal(expected, result); + } + + [Fact] + public void CompareValues_ReferenceType_EqualityByString_NoThrow() + { + // A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw, + // and ordering operators must return false rather than throwing. + var ex = Record.Exception(() => + { + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">")); + }); + Assert.Null(ex); + } + + [Fact] + public void CompareValues_TimeSpan_ParsesViaSpanParsable() + { + // TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently + // returned no-match. ISpanParsable parses it correctly. + var prop = TimeSpan.FromMinutes(5); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "=")); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">")); + } + + [Fact] + public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow() + { + var ex = Record.Exception(() => + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "="))); + Assert.Null(ex); + } + + [Fact] + public void CompareValues_Guid_ValueTypeParsedViaTypes() + { + // Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and + // compared by value. + var g = Guid.Parse("00000000-0000-0000-0000-000000000001"); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "=")); + } + + // A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface — + // the Faction/Town shape. Types must still discover its Parse by reflection. + private sealed class LegacyParseType + { + public string Value { get; private init; } + public static LegacyParseType Parse(string s) => new() { Value = s }; + public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + } + + [Fact] + public void CompareValues_LegacyParseString_ParsedViaTypes() + { + // Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the + // legacy Parse by reflection, so we compare against a real parsed instance, not the raw text. + var prop = LegacyParseType.Parse("alpha"); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "=")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs new file mode 100644 index 000000000..2bfe94af8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Concurrent; +using Server; +using Server.Engines.AdvancedSearch; +using Server.Items; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class AdvancedSearchWorkerTests +{ + // An item whose property test path will throw when evaluated. + private sealed class ThrowingItem : Item + { + public ThrowingItem() : base(0x1) { } + public ThrowingItem(Serial s) : base(s) { } + public string Boom => throw new InvalidOperationException("boom"); + } + + [Fact] + public void Worker_FilterThrows_DoesNotEscape_ReturnsNoMatch() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter + { + FilterPropertyTest = true, + PropertyTest = "Boom=1", // reflection GetValue -> throws + }; + + var item = new ThrowingItem(); + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(item); + worker.Sleep(); // drains; must not crash the test process + + Assert.Empty(results); + } + finally + { + item.Delete(); + worker.Exit(); + } + } + + [Fact] + public void Worker_DeletedEntity_IsSkipped() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter(); // no filters -> everything matches + + var item = new Item(0x1); + item.Delete(); + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(item); + worker.Sleep(); + + Assert.Empty(results); + } + finally + { + worker.Exit(); + } + } + + [Fact] + public void DoSearch_IsGuarded_AgainstReentry() + { + // White-box: flip the guard, assert a second entry is rejected, then clear. + // _searchInProgress is process-global static state; release it in finally so a + // failed assert here can't leak the guard into other tests. + Assert.False(AdvancedSearchGump.IsSearchInProgress); + Assert.True(AdvancedSearchGump.TryBeginSearch()); // acquires + try + { + Assert.False(AdvancedSearchGump.TryBeginSearch()); // rejected + } + finally + { + AdvancedSearchGump.EndSearch(); // releases + } + + Assert.False(AdvancedSearchGump.IsSearchInProgress); + } +} diff --git a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs index fa6de64a5..314290264 100644 --- a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs +++ b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs @@ -1,3 +1,4 @@ +using System; using Xunit; namespace Server.Tests.Utility; @@ -18,4 +19,29 @@ public class TryParseTests Assert.Equal(parsedAs, constructed); } } + + [Theory] + // Parsed directly into the target type (INumber.TryParse), not via ulong + Convert.ChangeType. + [InlineData(typeof(int), "42", true, 42)] + [InlineData(typeof(int), "-5", true, -5)] // signed values parse directly now + [InlineData(typeof(int), "0xFF", true, 255)] // hex + [InlineData(typeof(int), "notanumber", false, null)] + [InlineData(typeof(byte), "255", true, (byte)255)] + [InlineData(typeof(byte), "256", false, null)] // out of the byte range + [InlineData(typeof(uint), "4294967295", true, 4294967295u)] + [InlineData(typeof(long), "-9000000000", true, -9000000000L)] + public void TestTryParseNumeric(Type type, string value, bool success, object expected) + { + var error = Server.Types.TryParse(type, value, out var constructed); + + if (success) + { + Assert.Null(error); + Assert.Equal(expected, constructed); + } + else + { + Assert.NotNull(error); + } + } } diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index 58a7f73a9..7f72c02b2 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -8,6 +8,7 @@ using Server.Commands; using Server.Commands.Generic; using Server.Engines.Spawners; using Server.Gumps; +using Server.Logging; using Server.Network; using Server.Saves; @@ -28,11 +29,19 @@ public enum AdvancedSearchGumpOptions : long public class AdvancedSearchGump : Gump { + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchGump)); + private const int MaxEntries = 18; private static int _threadId; private static AdvancedSearchThreadWorker[] _threadWorkers; + private static int _searchInProgress; + + internal static bool IsSearchInProgress => Volatile.Read(ref _searchInProgress) == 1; + internal static bool TryBeginSearch() => Interlocked.CompareExchange(ref _searchInProgress, 1, 0) == 0; + internal static void EndSearch() => Volatile.Write(ref _searchInProgress, 0); + private static void Configure() { EventSink.Shutdown += Shutdown; @@ -124,6 +133,10 @@ public class AdvancedSearchGump : Gump public AdvancedSearchGump() : base(50, 50) => Build(); + // Entries on the current page — bounds the paging loop so a partial last page can't read past the end. + internal static int VisibleCount(int total, int displayFrom, int maxEntries) => + Math.Clamp(total - displayFrom, 0, maxEntries); + private void Build() { const int height = 500; @@ -319,15 +332,13 @@ public class AdvancedSearchGump : Gump var allDisplayedSelected = true; - for (var i = 0; i < MaxEntries; i++) - { - var offset = SortDescending ? MaxEntries - 1 - i : i; - var index = offset + DisplayFrom; + // Bound to this page's real entries so a partial last page still renders in descending mode. + var visibleCount = VisibleCount(SearchResults.Length, DisplayFrom, MaxEntries); - if (index >= SearchResults.Length) - { - break; - } + for (var i = 0; i < visibleCount; i++) + { + var offset = SortDescending ? visibleCount - 1 - i : i; + var index = offset + DisplayFrom; var entry = SearchResults[index]; @@ -739,78 +750,103 @@ public class AdvancedSearchGump : Gump return; } + if (!TryBeginSearch()) + { + from.SendMessage("A search is already running. Please wait for it to finish."); + return; + } + + _threadId = 0; + var autoSave = AutoSave.SavesEnabled; - if (autoSave) + AutoSave.SavesEnabled = false; + + try { - AutoSave.SavesEnabled = false; - } + _threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)]; - _threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)]; + var ignoreQueue = new ConcurrentQueue(); + var results = new ConcurrentQueue(); + var worldLocation = new WorldLocation(from.Location, from.Map); - var ignoreQueue = new ConcurrentQueue(); - var results = new ConcurrentQueue(); - var worldLocation = new WorldLocation(from.Location, from.Map); - - for (var i = 0; i < _threadWorkers.Length; i++) - { - (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); - } - - var type = Filter.FilterType ? Filter.Type : null; - - // Push the entities - foreach (var item in World.Items.Values) - { - if (type == null || type.IsInstanceOfType(item)) - { - PushToWorkers(item); - } - } - - foreach (var m in World.Mobiles.Values) - { - if (type == null || type.IsInstanceOfType(m)) - { - PushToWorkers(m); - } - } - - ThreadPool.QueueUserWorkItem(state => - { - // Block until everything is processed for (var i = 0; i < _threadWorkers.Length; i++) { - _threadWorkers[i].Sleep(); + (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); } - var ignoredEntities = new HashSet(ignoreQueue); + var type = Filter.FilterType ? Filter.Type : null; - // Force the GC to collect the ignored entities - ignoreQueue.Clear(); - - var resultsList = new List(results.Count); - foreach (var result in results) + // Push the entities. Workers read entity state concurrently with the main loop — + // see AdvancedSearchThreadWorker for the accepted, bounded race. + foreach (var item in World.Items.Values) { - if (!ignoredEntities.Contains(result.Entity)) + if (type == null || type.IsInstanceOfType(item)) { - resultsList.Add(result); + PushToWorkers(item); } } - SearchResults = resultsList.ToArray(); - - // Force the GC to collect the results - resultsList.Clear(); - resultsList.TrimExcess(); - - // Send the gump on the main thread - Core.LoopContext.Post( - autoSaveState => + foreach (var m in World.Mobiles.Values) + { + if (type == null || type.IsInstanceOfType(m)) { - AutoSave.SavesEnabled = (bool)autoSaveState!; - Resend(from); - }, state); - }, autoSave); + PushToWorkers(m); + } + } + + ThreadPool.QueueUserWorkItem(state => + { + try + { + // Block until everything is processed + for (var i = 0; i < _threadWorkers.Length; i++) + { + _threadWorkers[i].Sleep(); + } + + var ignoredEntities = new HashSet(ignoreQueue); + + // Force the GC to collect the ignored entities + ignoreQueue.Clear(); + + var resultsList = new List(results.Count); + foreach (var result in results) + { + if (!ignoredEntities.Contains(result.Entity)) + { + resultsList.Add(result); + } + } + + SearchResults = resultsList.ToArray(); + + // Force the GC to collect the results + resultsList.Clear(); + resultsList.TrimExcess(); + + // Send the gump on the main thread + Core.LoopContext.Post(() => Resend(from)); + } + catch (Exception ex) + { + // A drain-phase throw here would terminate the process; the finally still + // restores autosave and releases the guard. + _logger.Warning(ex, "AdvancedSearch: search drain failed"); + } + finally + { + AutoSave.SavesEnabled = (bool)state!; + EndSearch(); + } + }, autoSave); + } + catch + { + // Setup failed before the work item took ownership of the release. + AutoSave.SavesEnabled = autoSave; + EndSearch(); + throw; + } } private void SetSortSwitches(int radioSwitch) diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index d63c0a442..a4a26c244 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -3,13 +3,26 @@ using System.Collections.Concurrent; using System.Reflection; using System.Threading; using Server.Items; +using Server.Logging; using Server.Mobiles; using Server.Multis; namespace Server.Engines.AdvancedSearch; +/// +/// Filters entities on a background thread while the main loop keeps mutating them — an +/// intentional, bounded race. Reads of live / state are +/// unsynchronized, so a torn read may report stale coordinates, and any +/// getter that throws mid-read is caught per-entity in and skipped. +/// Results are best-effort and may omit a concurrently modified entity, but never fault or corrupt +/// server state. Eliminating the race would require snapshotting each read field onto the main +/// thread before handing entities off; that is deferred. +/// public class AdvancedSearchThreadWorker { + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchThreadWorker)); + private static readonly ConcurrentDictionary _propCache = new(); + private readonly Thread _thread; private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working private readonly AutoResetEvent _stopEvent; // Main thread waits for the worker finish draining @@ -26,7 +39,10 @@ public class AdvancedSearchThreadWorker _startEvent = new AutoResetEvent(false); _stopEvent = new AutoResetEvent(false); _entities = new ConcurrentQueue(); - _thread = new Thread(Execute); + _thread = new Thread(Execute) + { + IsBackground = true + }; _thread.Start(this); } @@ -52,10 +68,16 @@ public class AdvancedSearchThreadWorker public void Exit() { - _exit = true; + Volatile.Write(ref _exit, true); Wake(WorldLocation.Zero, null, null, null); - Sleep(); + + // Tolerate a worker that has already terminated (e.g. Core.Closing raced us) so + // shutdown can't deadlock waiting on a stopEvent that will never be set. + if (_thread.IsAlive) + { + Sleep(); + } } public void Push(IEntity entity) @@ -88,12 +110,17 @@ public class AdvancedSearchThreadWorker worker._filter = null; break; } + else + { + // Transiently empty but not yet paused: yield rather than busy-spin. + Thread.Yield(); + } } worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished - worker._pause = false; + Volatile.Write(ref worker._pause, false); - if (Core.Closing || worker._exit) + if (Core.Closing || Volatile.Read(ref worker._exit)) { return; } @@ -102,9 +129,28 @@ public class AdvancedSearchThreadWorker private AdvancedSearchResult DoEntitySearch(IEntity entity) { - if (_filter.HideValidInternalMap) + if (entity == null || entity.Deleted) { - HandleValidInternal(entity); + return null; + } + + try + { + return DoEntitySearchCore(entity); + } + catch (Exception ex) + { + _logger.Warning(ex, "AdvancedSearch: filter threw for {Entity}; skipping", entity); + return null; + } + } + + private AdvancedSearchResult DoEntitySearchCore(IEntity entity) + { + if (_filter == null) + { + // Exit() clears the filter; a straggler entity dequeued after teardown bails here. + return null; } // Check for valid map @@ -138,6 +184,12 @@ public class AdvancedSearchThreadWorker return null; } + // After the cheap filters, so non-qualifying entities skip the house/keyring enumeration. + if (_filter.HideValidInternalMap) + { + HandleValidInternal(entity); + } + if (entity is Mobile mobile) { return DoMobileSearch(mobile); @@ -296,27 +348,17 @@ public class AdvancedSearchThreadWorker } } - private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) - { - var atIndex = span.IndexOf('@'); - var orIndex = span.IndexOf('|'); - - if (atIndex == -1 && orIndex == -1) - { - return EvaluateSingleExpression(entity, span); - } - - var result = atIndex != -1; - var splitIndex = result ? atIndex : orIndex; - - var left = EvaluateRecursive(entity, span.Slice(0, splitIndex)); - var right = EvaluateRecursive(entity, span.Slice(splitIndex + 1)); - - return result ? left && right : left || right; - } + private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) => + AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf)); private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan expression) { + expression = expression.Trim(); + if (expression.Length == 0) + { + return false; + } + var negate = false; if (expression[0] == '~') { @@ -338,7 +380,7 @@ public class AdvancedSearchThreadWorker return false; } - var properties = entity.GetType().GetProperties(); + var properties = _propCache.GetOrAdd(entity.GetType(), static t => t.GetProperties()); PropertyInfo property = null; for (var i = 0; i < properties.Length; ++i) { diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 8d7dae3c4..2c349847d 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -43,77 +43,80 @@ public static class AdvancedSearchUtilities if (propertyType == typeof(long)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(ulong)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(int)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(uint)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(short)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(ushort)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(sbyte)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(byte)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(float)) { - var parsedValue = ParseValue(valuePart); - return Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); } if (propertyType == typeof(double)) { - var parsedValue = ParseValue(valuePart); - return Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); } if (propertyType == typeof(string)) { - var parsedValue = ParseValue(valuePart); - return Compare((string)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((string)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(TimeSpan)) { - var parsedValue = ParseValue(valuePart); - return Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(DateTime)) { - var parsedValue = ParseValue(valuePart); - return Compare((DateTime)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((DateTime)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(bool)) { - var parsedValue = ParseValue(valuePart); - return Compare((bool)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((bool)propertyValue!, parsedValue, operatorSpan); } if (propertyType.IsEnum) { - var valueEnum = Enum.Parse(propertyType, valuePart, false); + if (!Enum.TryParse(propertyType, valuePart.ToString(), true, out var valueEnum) || valueEnum == null) + { + return false; + } return GetEnumSize(propertyType) switch { @@ -121,15 +124,17 @@ public static class AdvancedSearchUtilities 2 => CompareNumeric((short)propertyValue!, (short)valueEnum, operatorSpan), 4 => CompareNumeric((int)propertyValue!, (int)valueEnum, operatorSpan), 8 => CompareNumeric((long)propertyValue!, (long)valueEnum, operatorSpan), + _ => false }; } - if (!propertyType.IsValueType) - { - var parsedValue = ParseValue(valuePart); - return CompareReference(propertyValue!, parsedValue, operatorSpan); - } - - return false; + // Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity + // properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy + // RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared, + // thread-safe Types converter so the target is parsed into the property's real type, then + // compare by value. A string is allocated here, but this is the uncommon path; the common + // types never reach it. Types returns a non-null message when it can't parse -> no match. + return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null && + CompareReference(propertyValue!, parsed, operatorSpan); } public static bool CompareNumeric(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => @@ -236,19 +241,40 @@ public static class AdvancedSearchUtilities _ => false }; - public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch + public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) + { + switch (operatorSpan) { - "=" or "==" => propertyValue.Equals(parsedValue), - "!" or "!=" => !propertyValue.Equals(parsedValue), - ">" => Comparer.Default.Compare(propertyValue, parsedValue) > 0, - "<" => Comparer.Default.Compare(propertyValue, parsedValue) < 0, - ">=" => Comparer.Default.Compare(propertyValue, parsedValue) >= 0, - "<=" => Comparer.Default.Compare(propertyValue, parsedValue) <= 0, - _ => false - }; + case "=": + case "==": return Equals(propertyValue, parsedValue); + case "!": + case "!=": return !Equals(propertyValue, parsedValue); + } - public static T ParseValue(ReadOnlySpan valuePart) + if (propertyValue is IComparable cmp && parsedValue != null) + { + try + { + var c = cmp.CompareTo(parsedValue); + return operatorSpan switch + { + ">" => c > 0, + "<" => c < 0, + ">=" => c >= 0, + "<=" => c <= 0, + _ => false + }; + } + catch + { + return false; + } + } + + return false; + } + + internal static bool TryParseValue(ReadOnlySpan valuePart, out T value) { // Special handling for boolean and hexadecimal values if (typeof(T) == typeof(bool)) @@ -256,74 +282,149 @@ public static class AdvancedSearchUtilities var val = valuePart.ToString().ToLower(); if (val is "true" or "1" or "enabled" or "on") { - return (T)(object)true; + value = (T)(object)true; + return true; } if (val is "false" or "0" or "disabled" or "off") { - return (T)(object)false; + value = (T)(object)false; + return true; } + + value = default; + return false; } if (typeof(T) == typeof(long)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(ulong)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(int)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(uint)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(short)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(ushort)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(sbyte)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(byte)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(float)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(double)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } - // Default parsing for other types - return (T)Convert.ChangeType(valuePart.ToString(), typeof(T)); + // string needs no parsing — the span itself is the value. + if (typeof(T) == typeof(string)) + { + value = (T)(object)valuePart.ToString(); + return true; + } + + // Remaining supported types (TimeSpan, DateTime) parse straight from the span via + // ISpanParsable — no allocation, no reflection, and unlike Convert.ChangeType it handles + // TimeSpan, which is not IConvertible and previously failed silently. + if (typeof(T) == typeof(TimeSpan)) + { + return TryParseSpanParsable(valuePart, out value); + } + + if (typeof(T) == typeof(DateTime)) + { + return TryParseSpanParsable(valuePart, out value); + } + + value = default; + return false; + } + + // Parses U (a value type exposing ISpanParsable) from the span and reinterprets it as T. The + // two type params mirror TryParseNumericValue: the caller dispatches on typeof(T), so U == T at + // every call site and the (T)(object) cast is always valid. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryParseSpanParsable(ReadOnlySpan valuePart, out T value) where U : ISpanParsable + { + if (U.TryParse(valuePart, null, out var parsed)) + { + value = (T)(object)parsed; + return true; + } + + value = default; + return false; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static R ParseNumericValue(ReadOnlySpan valuePart) where T : INumber => - valuePart.StartsWith("0x") - ? (R)(object)T.Parse(valuePart[2..], NumberStyles.HexNumber, null) - : (R)(object)T.Parse(valuePart, null); + private static bool TryParseNumericValue(ReadOnlySpan valuePart, out R value) where T : INumber + { + var ok = valuePart.StartsWith("0x") + ? T.TryParse(valuePart[2..], NumberStyles.HexNumber, null, out var parsed) + : T.TryParse(valuePart, null, out parsed); + + if (ok) + { + value = (R)(object)parsed; + return true; + } + + value = default; + return false; + } + + // Evaluates one trimmed leaf atom against caller-supplied state. A custom delegate is required + // because ReadOnlySpan cannot be a Func<> type argument; passing state avoids a per-call + // capturing closure, so the recursion allocates neither a string nor a closure. + internal delegate bool LeafEvaluator(TState state, ReadOnlySpan leaf); + + // OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND. + internal static bool EvaluateBoolean(ReadOnlySpan expr, TState state, LeafEvaluator evalLeaf) + { + var orIndex = expr.IndexOf('|'); + if (orIndex != -1) + { + return EvaluateBoolean(expr[..orIndex], state, evalLeaf) || EvaluateBoolean(expr[(orIndex + 1)..], state, evalLeaf); + } + + var andIndex = expr.IndexOf('@'); + if (andIndex != -1) + { + return EvaluateBoolean(expr[..andIndex], state, evalLeaf) && EvaluateBoolean(expr[(andIndex + 1)..], state, evalLeaf); + } + + return evalLeaf(state, expr.Trim()); + } private static int GetEnumSize(Type enumType) => Type.GetTypeCode(Enum.GetUnderlyingType(enumType)) switch diff --git a/Projects/UOContent/Engines/Factions/Core/Faction.cs b/Projects/UOContent/Engines/Factions/Core/Faction.cs index 8aa8085ca..33166f5a0 100644 --- a/Projects/UOContent/Engines/Factions/Core/Faction.cs +++ b/Projects/UOContent/Engines/Factions/Core/Faction.cs @@ -16,7 +16,7 @@ using Server.Targeting; namespace Server.Factions; [CustomEnum(["Minax", "Council of Mages", "True Britannians", "Shadowlords"])] -public abstract class Faction : IComparable +public abstract class Faction : IComparable, ISpanParsable { public const int StabilityFactor = 300; // 300% greater (3 times) than smallest faction public const int StabilityActivation = 200; // Stability code goes into effect when largest faction has > 200 people @@ -1313,7 +1313,27 @@ public abstract class Faction : IComparable return null; } - public static Faction Parse(string name) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Faction result) => + TryParse(s.AsSpan(), provider, out result); + + public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) { var factions = Factions; @@ -1321,13 +1341,15 @@ public abstract class Faction : IComparable { var faction = factions[i]; - if (faction.Definition.FriendlyName.InsensitiveEquals(name)) + if (s.InsensitiveEquals(faction.Definition.FriendlyName)) { - return faction; + result = faction; + return true; } } - return null; + result = null; + return false; } public static bool InSkillLoss(Mobile mob) => m_SkillLoss.ContainsKey(mob); diff --git a/Projects/UOContent/Engines/Factions/Core/Town.cs b/Projects/UOContent/Engines/Factions/Core/Town.cs index 8aa136ff6..a30dc23a6 100644 --- a/Projects/UOContent/Engines/Factions/Core/Town.cs +++ b/Projects/UOContent/Engines/Factions/Core/Town.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Server.Targeting; namespace Server.Factions; [CustomEnum(["Britain", "Magincia", "Minoc", "Moonglow", "Skara Brae", "Trinsic", "Vesper", "Yew"])] -public abstract class Town : IComparable +public abstract class Town : IComparable, ISpanParsable { public const int SilverCaptureBonus = 10000; @@ -482,7 +483,27 @@ public abstract class Town : IComparable return null; } - public static Town Parse(string name) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Town Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Town Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Town result) => + TryParse(s.AsSpan(), provider, out result); + + public static Town Parse(ReadOnlySpan s, IFormatProvider provider) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Town result) { var towns = Towns; @@ -490,13 +511,15 @@ public abstract class Town : IComparable { var town = towns[i]; - if (town.Definition.FriendlyName.InsensitiveEquals(name)) + if (s.InsensitiveEquals(town.Definition.FriendlyName)) { - return town; + result = town; + return true; } } - return null; + result = null; + return false; } [Usage("GrantTownSilver ")] diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index 810ff0310..fa669d96a 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Reflection; @@ -10,7 +11,8 @@ namespace Server { public static readonly Type[] ParseStringParamTypes = { typeof(string), typeof(IFormatProvider) }; public static readonly Type[] ParseStringNumericParamTypes = { typeof(string), typeof(NumberStyles) }; - private static object[] _parseParams = { null, null }; + // Legacy RunUO signature: a static Parse(string) that predates IParsable (e.g. Faction, Town). + public static readonly Type[] ParseStringSingleParamTypes = { typeof(string) }; public static readonly Type OfByte = typeof(byte); public static readonly Type OfSByte = typeof(sbyte); @@ -75,7 +77,9 @@ namespace Server OfULong }; - private static Dictionary _isParsable; + // Thread-safe: parse metadata is read from parallel callers (e.g. the Advanced Search workers), + // not just the single-threaded command path. + private static readonly ConcurrentDictionary _isParsable = new(); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsType(Type type, Type check) => check.IsAssignableFrom(type); @@ -89,25 +93,19 @@ namespace Server [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsText(Type t) => IsType(t, OfText); - public static bool IsParsable(Type t) - { - _isParsable ??= new(); - if (_isParsable.TryGetValue(t, out var isParsable)) + public static bool IsParsable(Type t) => + _isParsable.GetOrAdd(t, static type => { - return isParsable; - } - - foreach (var x in t.GetInterfaces()) - { - if (x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IParsable<>)) + foreach (var x in type.GetInterfaces()) { - isParsable = true; - break; + if (x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IParsable<>)) + { + return true; + } } - } - return _isParsable[t] = isParsable; - } + return false; + }); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsDecimal(Type t) => Array.IndexOf(DecimalTypes, t) >= 0; @@ -118,18 +116,81 @@ namespace Server [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsEntity(Type t) => OfEntity.IsAssignableFrom(t); - private static Dictionary _parseMethods; + private static readonly ConcurrentDictionary _parseMethods = new(); + + // A static string Parse method: the modern IParsable Parse(string, IFormatProvider), or a + // legacy RunUO Parse(string). Cached per type; null if the type has neither. (Span-based Parse + // can't be reflection-invoked — a ReadOnlySpan can't be boxed into the args array — so the + // string overloads are what we bind to.) + public static MethodInfo GetParseMethod(Type t) => + _parseMethods.GetOrAdd( + t, + static type => type.GetMethod("Parse", ParseStringParamTypes) + ?? type.GetMethod("Parse", ParseStringSingleParamTypes) + ); public static object Parse(Type t, string value) { - _parseMethods ??= new(); - if (!_parseMethods.TryGetValue(t, out var method)) + var method = GetParseMethod(t); + if (method == null) { - _parseMethods[t] = method = t.GetMethod("Parse", ParseStringParamTypes); + return null; } - _parseParams[0] = value; - return method?.Invoke(null, _parseParams); + // Fresh args array per call — a shared static array would race across concurrent callers. + // Arg shape depends on which overload we bound to (IParsable 2-arg vs legacy 1-arg). + var args = method.GetParameters().Length == 2 ? new object[] { value, null } : new object[] { value }; + return method.Invoke(null, args); + } + + // Parses directly into the concrete numeric type via INumber.TryParse (the Type-dispatched + // equivalent of a generic TryParse). Returns the boxed value; false if the text doesn't fit + // the type's range/format so the caller can fall through. + private static bool TryParseNumeric(Type type, ReadOnlySpan span, NumberStyles style, out object result) + { + if (type == OfInt && int.TryParse(span, style, null, out var i)) + { + result = i; + return true; + } + if (type == OfUInt && uint.TryParse(span, style, null, out var ui)) + { + result = ui; + return true; + } + if (type == OfLong && long.TryParse(span, style, null, out var l)) + { + result = l; + return true; + } + if (type == OfULong && ulong.TryParse(span, style, null, out var ul)) + { + result = ul; + return true; + } + if (type == OfShort && short.TryParse(span, style, null, out var s)) + { + result = s; + return true; + } + if (type == OfUShort && ushort.TryParse(span, style, null, out var us)) + { + result = us; + return true; + } + if (type == OfByte && byte.TryParse(span, style, null, out var b)) + { + result = b; + return true; + } + if (type == OfSByte && sbyte.TryParse(span, style, null, out var sb)) + { + result = sb; + return true; + } + + result = null; + return false; } // Do not use this in "Parse" methods, it may cause a stack overflow @@ -201,35 +262,38 @@ namespace Server if (IsNumeric(type)) { - try + var span = value.AsSpan(); + var style = NumberStyles.Integer; + if (span.StartsWithOrdinal("0x")) { - var isHex = value.StartsWithOrdinal("0x"); - var index = isHex ? 2 : 0; - if (ulong.TryParse(value.AsSpan(index), isHex ? NumberStyles.HexNumber : NumberStyles.Integer, null, out var num)) - { - if (isEntity) - { - constructed = World.FindEntity((Serial)num); - } - else if (isSerial) - { - constructed = (Serial)num; - } - else - { - constructed = Convert.ChangeType(num, type); - } + span = span[2..]; + style = NumberStyles.HexNumber; + } + if (isEntity || isSerial) + { + // Serial/entity properties were mutated to int above; a Serial is a uint, so parse + // the full 32-bit range as ulong and resolve. + if (ulong.TryParse(span, style, null, out var num)) + { + constructed = isEntity ? World.FindEntity((Serial)num) : (Serial)num; return null; } } - catch + else if (TryParseNumeric(type, span, style, out constructed)) { - return "That is not properly formatted."; + // Parse the string directly into the target type via INumber.TryParse — no + // Convert.ChangeType, and (unlike parse-as-ulong) signed and per-type ranges are honored. + return null; } + + // On parse failure, fall through to the Parse-method / Convert.ChangeType fallbacks below. } - if (IsParsable(type)) + // IParsable (Parse(string, IFormatProvider)) or a legacy RunUO Parse(string). Gating on + // the discovered method rather than the IParsable interface keeps pre-IParsable types + // (Faction, Town, ...) parseable for backwards compatibility. + if (GetParseMethod(type) != null) { try { diff --git a/dev-docs/runuo-migration-docs/01-foundation-changes.md b/dev-docs/runuo-migration-docs/01-foundation-changes.md index 0af53c6d7..668701bea 100644 --- a/dev-docs/runuo-migration-docs/01-foundation-changes.md +++ b/dev-docs/runuo-migration-docs/01-foundation-changes.md @@ -282,6 +282,65 @@ public MyItem(Serial serial) : base(serial) { } // ModernUO — DELETE THIS CONSTRUCTOR. The source generator creates it. ``` +## 15. Static `Parse(string)` → `IParsable` / `ISpanParsable` + +RunUO predates `IParsable`/`ISpanParsable` (C# 11 / .NET 7 static-abstract interface +members), so RunUO types that convert from a string expose a bare `public static T Parse(string value)`. +**ModernUO expects any such type to implement `IParsable` (string) and, where practical, +`ISpanParsable` (span; it extends `IParsable`, so implement span and you get both).** + +This matters because the engine's string→value converter, `Server.Types.TryParse` — used by `[set`, +`[props`, spawner property assignment, the conditional-command compiler (`[where`), and Advanced +Search — binds to the `Parse(string, IFormatProvider)` signature. A type with **only** a legacy +`Parse(string)` is discovered by `Types` through a reflection fallback, but that fallback is a safety +net, not the intended path: a bare `Parse(string)` is easy to miss, doesn't participate in the +span-based fast paths, and (if it returns `null` instead of throwing) makes `[set` silently assign +`null` on bad input. Convert it. + +The `Parse` overloads throw `FormatException` on failure; `TryParse` returns `false`. Delegate the +string overloads to a span core (see `Race`, `Poison`, `Point3D` for the established pattern): + +```csharp +// RunUO +public abstract class Faction : IComparable +{ + public static Faction Parse(string name) // returns null on no-match — wrong contract, not IParsable + { + // ... linear search by name ... + return null; + } +} + +// ModernUO +public abstract class Faction : IComparable, ISpanParsable +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Faction result) => + TryParse(s.AsSpan(), provider, out result); + + public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) => + TryParse(s, provider, out var result) + ? result + : throw new FormatException($"The input string '{s}' was not in a correct format."); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) + { + // ... linear search by name using s.InsensitiveEquals(...) ... + result = null; + return false; + } +} +``` + +To find un-migrated types: search for `public static [A-Za-z0-9_<>]+ Parse\(string ` and check whether +the declaring type lists `IParsable`/`ISpanParsable`. + ## Quick Checklist When migrating any RunUO script, apply these changes in order: @@ -300,6 +359,7 @@ When migrating any RunUO script, apply these changes in order: 12. [ ] Modernize property syntax 13. [ ] Remove `Serial` constructor (handled by serialization generator) 14. [ ] Update usings +15. [ ] Convert bare static `Parse(string)` to `IParsable`/`ISpanParsable` ## See Also From e4827fc57b7a58edd64cc0b6da0ea5e49ff5c636 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:30:08 -0700 Subject: [PATCH 16/64] chore(deps): bump actions/upload-artifact from 4 to 7 (#2544) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1b3fb032b..7107c6009 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -64,7 +64,7 @@ jobs: fi - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: TestResults-${{ matrix.name }} path: ./TestResults @@ -150,7 +150,7 @@ jobs: fi - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: TestResults-${{ matrix.name }} path: ./TestResults From bec4cfa910a7733ecf3154891e3d3308dc0673c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:30:40 -0700 Subject: [PATCH 17/64] chore(deps): bump actions/setup-dotnet from 5 to 6 (#2545) Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-test.yml | 4 ++-- .github/workflows/build-tool-release.yml | 2 +- .github/workflows/create-release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 7107c6009..e7bf70d70 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -40,7 +40,7 @@ jobs: with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Install Prerequisites @@ -134,7 +134,7 @@ jobs: with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Build diff --git a/.github/workflows/build-tool-release.yml b/.github/workflows/build-tool-release.yml index 6cf70e799..696a825dc 100644 --- a/.github/workflows/build-tool-release.yml +++ b/.github/workflows/build-tool-release.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 270b9c1c6..3c0d182a3 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Compute version From c39454137e6f99491f5f98082a7cbeeb7c3a7be3 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:59:37 -0700 Subject: [PATCH 18/64] feat(network): pluggable connection filters; file blocklist + contribute-first CrowdSec (#2542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes IP banning around one idea: **core owns the question, content owns every answer.** Core gains a single accept-path seam — `IConnectionFilter` — and loses everything that used to implement one. The firewall moves to UOContent, a new file-backed blocklist joins it there, and CrowdSec is repositioned from an in-app enforcer to a contribute-first reporter. ## The seam ```csharp public interface IConnectionFilter { string Name { get; } void Configure(); void Start(CancellationToken token); void Stop(); bool ShouldDeny(IPAddress address); } ``` The accept path went from hardcoded branches to one question: ```csharp else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy)) { logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy); } ``` Filters register during the Configure sweep. The registry is a plain array walked by an indexed loop — no enumerator, no closure, no allocation — and the first denial short-circuits. An interface dispatch is noise next to the `accept()` syscall, so pluggability costs nothing measurable on the path that has to survive a DDoS. Whatever a hit implies — persisting, promoting to an OS bouncer, contributing to the ban channel — is the filter's business, not the accept path's. A filter that throws is **unregistered and the connection fails open**. A filter that faults once faults for every subsequent connection, so leaving it registered means an exception and a log line per accept — exactly the amplification an attacker wants — and a broken filter must not be able to deny everyone either. This deliberately does **not** reuse `EventSink.InvokeSocketConnect`: that fires later and allocates a `SocketConnectEventArgs` per connection, which is what the accept path avoids for rejected traffic. ## What ships behind it **`firewall`** (UOContent) — the existing admin-curated set. Collapsed from `Firewall` + `AdminFirewall` + a threaded enforcer into one single-threaded store with **zero concurrency primitives**: the accept path, admin gump, TTL expiry and boot load all run on the game loop. Persists to `Configuration/firewall.json` with automatic migration from the legacy `firewall.cfg`. No behavior change for operators — same namespace, same gump, same commands. **`blocklist`** (UOContent) — new. Holds a millions-strong list in-app and **demand-pages** hits up to CrowdSec, which promotes them to the OS firewall. The motivation is concrete: CrowdSec's Windows bouncer cannot load the ~3.9M IPs that 91 community feeds produce, but it handles ~100k fine. So the millions live in-process behind a binary search, and only addresses that *actually connect* get promoted. A `PromotedGuard` suppresses re-reporting an address until the bouncer picks it up. The list is parsed straight from UTF-8 file bytes with no per-line string allocation, off the game loop, and published as an immutable snapshot swapped through a single `volatile` reference. Reloads yield to world saves. **`tools/Export-IpBlocklist.ps1`** — the producer. Requires PowerShell 7 and runs on Windows, Linux and macOS; Windows PowerShell 5.1 is refused up front via `#requires`. Merges a thin, non-overlapping feed set into one de-duplicated, bogon-filtered file. Parsing runs in a compiled `Add-Type` hot loop (~1s for ~4M lines instead of minutes). Written to a `.tmp` sibling and swapped with `File.Replace`, so the shard never reads a half-written list, and a total feed outage refuses to overwrite a good list with an empty one. Re-running is idempotent — it exits without downloading anything while the list on disk is younger than `-MinInterval` (default 2h, the anchor feed's own refresh period), so a misconfigured scheduler can't hammer upstream. ## CrowdSec: contribute-first `IBanReporter` + `BanChannel` fan locally-decided bans out to external systems. `CrowdSecReporter` (UOContent) posts to LAPI `POST /v1/alerts` and retracts via `DELETE /v1/decisions`. Reporting is **enqueue-only** on the accept path: a bounded, coalescing channel drained off-loop with bounded retry, counted drops on overflow, and a flush on shutdown. Under a DDoS the accept path never does synchronous or lock-contending per-IP work. ### Why not pull decisions from CrowdSec? The original design streamed decisions into an in-app snapshot and enforced them at the accept gate. That's the wrong layer: by the time the shard sees the connection, the TCP handshake and socket setup are already paid for. `cs-firewall-bouncer` drops the same traffic **at the kernel**, and it's what CrowdSec is built to do. So the shard now contributes what it uniquely knows (rate-limit trips, blocklist hits from real connection attempts) and lets the OS enforce. The one thing the OS can't do — hold millions of entries on Windows — is exactly what the in-app blocklist covers, and it feeds the same pipeline. ## Threading policy `CLAUDE.md` rule #3 is rewritten as an explicit three-part policy, with rule #10 restated in tandem: - Anything touching game state runs **only** on the main loop. - Heavy work that *needs* game state must be **chunked** across ticks, never threaded. - Heavy work that does *not* need game state (large-file parse, external I/O) **must** run off-loop **and must yield to world saves**. Results come back via an immutable snapshot swapped through a single `volatile` reference, or `Core.LoopContext.Post` — never by letting the scheduler decide where heavy work runs. Both new subsystems follow it. ## Shared primitives `SortedRangeIndex where T : IBinaryInteger` — coalesced disjoint interval arrays plus a binary search. The firewall, the blocklist, and (as of this PR) core's reserved-network tables all use it. Coalescing is a correctness requirement, not an optimization: multi-feed lists nest CIDRs (`/24` containing a `/32`), and a search that inspects only the rightmost run whose minimum is ≤ the value is sound **only** over disjoint runs. That bug was caught in review and is covered by regression tests. `IPAddressUtility` collects the allocation-free `IPAddress` ↔ `UInt128` conversions and CIDR parsing that were previously scattered or duplicated. ## Config | File | Owner | Keys | |---|---|---| | `Configuration/bans.json` | core | `reportRateLimitTrips`, `autoBanDuration` | | `Configuration/blocklist.json` | content | `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | | `Configuration/crowdsec.json` | content | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | | `Configuration/firewall.json` | content | persisted firewall entries (migrated from `firewall.cfg`) | Everything is inert by default. CrowdSec self-disables without credentials; the blocklist self-disables until its file exists. A shard that changes nothing sees no behavior change. ## Notes for review - **Core no longer references `Firewall` or `IFirewallEntry` anywhere.** `NetworkUtilities` used to build its reserved-network tables out of `CidrFirewallEntry`, which coupled core to the firewall for something unrelated to banning; those are now a `SortedRangeIndex`, same semantics and public API. - **`BanChannel.Stop()` no longer persists the firewall** — a contribution coordinator has no business saving an enforcement store. That's the firewall filter's `Stop()`. - **A dead `whitelisted` parameter was dropped** from the blocklist gate: it was hardcoded `false` at its only call site, and no whitelist concept exists in core. - **The blocklist filter is an instance, not a static.** The static version forced its tests onto the sequential collection with a reset hook; they now run in parallel. - `dev-docs/networking-packets.md` documents the seam for content authors, plus a known wart in the `IPAddress` ↔ `UInt128` normalization flagged for a follow-up PR. - The generator was verified on Linux, macOS and Windows under a temporary CI matrix (since removed). It caught two portability bugs — a Windows-only path separator, and a culture-sensitive duration parse that read `2.5` as `25` on comma-decimal locales and *silently* turned a 2.5h cooldown into 25h — plus a third that made the script unparseable on Windows PowerShell 5.1. The source is ASCII-only for that last reason: `#requires` is only honored once a file parses, so non-ASCII in a BOM-less script produces parse errors instead of the version message. ## Tests **1344 pass** (782 `Server.Tests`, 562 `UOContent.Tests`). New coverage: filter registry (registration, short-circuit, fault-disable), blocklist parsing/CIDR/coalescing, snapshot reload markers, promote-guard TTL, ban-channel fan-out, CrowdSec alert building/dedup/flush-on-stop, and the generator's output-format contract pinned against the reader. --- .gitignore | 12 +- CLAUDE.md | 4 +- .../Fixtures/TestServerInitializer.cs | 12 +- .../Tests/Network/Bans/BanChannelTests.cs | 73 +++ .../Network/Bans/BanConfigurationTests.cs | 44 ++ .../Tests/Network/ConnectionFiltersTests.cs | 133 +++++ .../Tests/Network/Firewall/FirewallTests.cs | 137 ----- .../Server/Collections/SortedRangeIndex.cs | 134 +++++ Projects/Server/Main.cs | 9 +- Projects/Server/Network/Bans/BanChannel.cs | 143 +++++ .../Server/Network/Bans/BanConfiguration.cs | 76 +++ Projects/Server/Network/Bans/IBanReporter.cs | 55 ++ Projects/Server/Network/ConnectionFilters.cs | 155 +++++ Projects/Server/Network/Firewall/Firewall.cs | 168 ------ Projects/Server/Network/IConnectionFilter.cs | 60 ++ .../Network/NetState/NetState.Network.cs | 13 +- Projects/Server/Utilities/IPAddressUtility.cs | 240 ++++++++ Projects/Server/Utilities/NetworkUtilities.cs | 53 +- Projects/Server/Utilities/Utility.cs | 39 -- .../Fixtures/TestServerInitializer.cs | 12 +- .../Blocklist/BlocklistConfigurationTests.cs | 65 +++ .../Bans/Blocklist/BlocklistFileTests.cs | 85 +++ .../Bans/Blocklist/BlocklistFilterTests.cs | 94 +++ .../Bans/Blocklist/BlocklistSnapshotTests.cs | 98 ++++ .../Bans/Blocklist/PromotedGuardTests.cs | 46 ++ .../Network/Bans/CrowdSecAlertClientTests.cs | 51 ++ .../Bans/CrowdSecConfigurationTests.cs | 82 +++ .../Network/Bans/CrowdSecReporterTests.cs | 229 ++++++++ .../Network/Firewall/FirewallEntryTests.cs | 0 .../Firewall/FirewallPersistenceTests.cs | 72 +++ .../Tests/Network/Firewall/FirewallTests.cs | 89 +++ .../Commands/Generic/Commands/Commands.cs | 4 +- Projects/UOContent/Gumps/AdminGump.cs | 34 +- Projects/UOContent/Misc/AdminFirewall.cs | 139 ----- .../Misc/Blocklist/BlocklistConfiguration.cs | 90 +++ .../UOContent/Misc/Blocklist/BlocklistFile.cs | 86 +++ .../Misc/Blocklist/BlocklistFilter.cs | 259 +++++++++ .../Misc/Blocklist/BlocklistSnapshot.cs | 205 +++++++ .../UOContent/Misc/Blocklist/PromotedGuard.cs | 55 ++ .../UOContent/Misc/CrowdSec/CrowdSecAlert.cs | 65 +++ .../Misc/CrowdSec/CrowdSecAlertClient.cs | 137 +++++ .../Misc/CrowdSec/CrowdSecConfiguration.cs | 101 ++++ .../Misc/CrowdSec/CrowdSecReporter.cs | 399 +++++++++++++ .../Misc}/Firewall/BaseFirewallEntry.cs | 0 .../Misc}/Firewall/CidrFirewallEntry.cs | 31 +- Projects/UOContent/Misc/Firewall/Firewall.cs | 413 +++++++++++++ .../Misc/Firewall/FirewallConnectionFilter.cs | 52 ++ .../Misc/Firewall/FirewallSettings.cs | 42 ++ .../Misc}/Firewall/IFirewallEntry.cs | 0 .../Misc}/Firewall/SingleIpFirewallEntry.cs | 0 dev-docs/networking-packets.md | 64 +++ tools/Export-IpBlocklist.ps1 | 543 ++++++++++++++++++ 52 files changed, 4655 insertions(+), 547 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs create mode 100644 Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs create mode 100644 Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs delete mode 100644 Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs create mode 100644 Projects/Server/Collections/SortedRangeIndex.cs create mode 100644 Projects/Server/Network/Bans/BanChannel.cs create mode 100644 Projects/Server/Network/Bans/BanConfiguration.cs create mode 100644 Projects/Server/Network/Bans/IBanReporter.cs create mode 100644 Projects/Server/Network/ConnectionFilters.cs delete mode 100644 Projects/Server/Network/Firewall/Firewall.cs create mode 100644 Projects/Server/Network/IConnectionFilter.cs create mode 100644 Projects/Server/Utilities/IPAddressUtility.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs rename Projects/{Server.Tests => UOContent.Tests}/Tests/Network/Firewall/FirewallEntryTests.cs (100%) create mode 100644 Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs delete mode 100644 Projects/UOContent/Misc/AdminFirewall.cs create mode 100644 Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs create mode 100644 Projects/UOContent/Misc/Blocklist/BlocklistFile.cs create mode 100644 Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs create mode 100644 Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs create mode 100644 Projects/UOContent/Misc/Blocklist/PromotedGuard.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs create mode 100644 Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs rename Projects/{Server/Network => UOContent/Misc}/Firewall/BaseFirewallEntry.cs (100%) rename Projects/{Server/Network => UOContent/Misc}/Firewall/CidrFirewallEntry.cs (70%) create mode 100644 Projects/UOContent/Misc/Firewall/Firewall.cs create mode 100644 Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs create mode 100644 Projects/UOContent/Misc/Firewall/FirewallSettings.cs rename Projects/{Server/Network => UOContent/Misc}/Firewall/IFirewallEntry.cs (100%) rename Projects/{Server/Network => UOContent/Misc}/Firewall/SingleIpFirewallEntry.cs (100%) create mode 100644 tools/Export-IpBlocklist.ps1 diff --git a/.gitignore b/.gitignore index 7bb942e52..222cdabf2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,12 @@ /Distribution/bsdtar /Distribution/Configuration/antimacro.json /Distribution/Configuration/assistants.json +/Distribution/Configuration/bans.json +/Distribution/Configuration/blocklist.json +/Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json +/Distribution/Configuration/ip-blocklist.txt +/Distribution/Configuration/ip-blocklist.txt.tmp /Distribution/Configuration/modernuo.json /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json @@ -19,6 +24,7 @@ /Distribution/Backups /Distribution/Saves /Distribution/docs +/docs/ /Distribution/temp /Distribution/*.dylib /Distribution/*.so @@ -46,5 +52,7 @@ /packages/* /Distribution/Configuration/server-access.json -# BuildTool native binaries (downloaded from GitHub Releases) -/tools/ +# BuildTool native binaries (downloaded from GitHub Releases). +# Ignore everything under tools/ except the operator scripts checked in below. +/tools/* +!/tools/Export-IpBlocklist.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 5335c036d..1b52b6816 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md` 2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))` → `logger.Information(...)` (requires `using Server.Logging;`) -3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded. +3. **Threading policy** — game logic runs only on the main loop; **never** touch game state (`World`, mobiles, items, maps, timers) from a background thread. Heavy work that *needs* game state must be **chunked** across ticks, not threaded. Heavy work that does *not* need game state (large-file parse, external I/O) **must** run on a background thread **and must yield to world saves** (defer while `World.Saving`/`WorldState.PendingSave`). Publish results back to the loop as an immutable snapshot swapped via a single `volatile` reference — the only sanctioned `volatile`. No `lock`/`Mutex`/`ConcurrentDictionary` in game logic. Rule #10 covers how background work hands results back to the loop → `dev-docs/threading-model.md` 4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange()`, `map.GetItemsInRange()` 5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct 9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` -10. **No `Task.Run`/`new Thread()`** in game code — game logic is single-threaded event loop +10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code 13. **No empty gumps** — every gump must produce visual elements. An empty gump leaks on client+server (no way to close it). Use static `DisplayTo()` to validate before constructing → `dev-docs/gump-system.md` diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index e84c16509..845efb115 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Reflection; using System.Threading; @@ -78,6 +79,15 @@ internal static class TestServerInitializer Core.LoopContext = new EventLoopContext(); Core.Expansion = Expansion.EJ; + // Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is + // DateTime.MinValue for the whole test host. + Core._now = DateTime.UtcNow; + + // Timer wheel must exist before NetState.Configure(), which schedules a recurring + // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs + // before AssemblyHandler.Invoke("Configure")). + Timer.Init(0); + // Configure networking (initializes RingSocketManager for tests) Server.Network.NetState.Configure(); @@ -87,8 +97,6 @@ internal static class TestServerInitializer // Configure the world World.Configure(); - Timer.Init(0); - // Load the world World.Load(); diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs new file mode 100644 index 000000000..a48647152 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class BanChannelTests +{ + private sealed class FakeReporter : IBanReporter + { + public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; + public readonly List Retractions = []; + public bool ThrowOnReport; + + public string Name => "fake"; + public bool CanRetract => true; + public void Register() { } + public void Start(CancellationToken token) { } + public void Stop() { } + + public void Report(IPAddress address, TimeSpan ttl, string reason) + { + if (ThrowOnReport) + { + throw new InvalidOperationException("boom"); + } + + Reports.Add((address, ttl, reason)); + } + + public void Retract(IPAddress address) => Retractions.Add(address); + } + + [Fact] + public void Report_FansOutToAllReporters() + { + var a = new FakeReporter(); + var b = new FakeReporter(); + BanChannel.ConfigureForTesting([a, b]); + + BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "rate-limit"); + + Assert.Single(a.Reports); + Assert.Single(b.Reports); + Assert.Equal("rate-limit", a.Reports[0].reason); + } + + [Fact] + public void Report_SwallowsReporterException() + { + var bad = new FakeReporter { ThrowOnReport = true }; + var good = new FakeReporter(); + BanChannel.ConfigureForTesting([bad, good]); + + BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "manual"); + + Assert.Single(good.Reports); // the throwing reporter does not block the others + } + + [Fact] + public void Retract_ReachesRetractCapableReporters() + { + var a = new FakeReporter(); + BanChannel.ConfigureForTesting([a]); + + BanChannel.Retract(IPAddress.Parse("9.9.9.9")); + + Assert.Single(a.Retractions); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs new file mode 100644 index 000000000..ee232b1df --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests; + +public class BanConfigurationTests +{ + // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so + // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently + // binds nothing. These tests round-trip through the exact options the loader uses. + + [Fact] + public void BanSettings_RoundTripsThroughJsonConfig() + { + var original = new BanSettings + { + ReportRateLimitTrips = false, + AutoBanDuration = TimeSpan.FromHours(2) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"reportRateLimitTrips\"", json); + Assert.Contains("\"autoBanDuration\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips); + Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives + } + + [Fact] + public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan() + { + var settings = new BanSettings(); + + Assert.True(settings.ReportRateLimitTrips); + Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration); + } +} diff --git a/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs new file mode 100644 index 000000000..b7e134794 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs @@ -0,0 +1,133 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ConnectionFiltersTests.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.Net; +using System.Threading; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +[Collection("Sequential Server Tests")] +public class ConnectionFiltersTests : IDisposable +{ + public ConnectionFiltersTests() => ConnectionFilters.ResetForTesting(); + + public void Dispose() => ConnectionFilters.ResetForTesting(); + + [Fact] + public void No_filters_denies_nothing() + { + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); + Assert.Null(deniedBy); + } + + [Fact] + public void Register_is_idempotent_by_name() + { + ConnectionFilters.Register(new FakeFilter("dupe", deny: false)); + ConnectionFilters.Register(new FakeFilter("dupe", deny: true)); + + // The second registration is ignored, so the deny:true instance never gets consulted. + Assert.Single(ConnectionFilters.Filters); + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); + } + + [Fact] + public void First_denying_filter_short_circuits_and_is_named() + { + var first = new FakeFilter("allow-all", deny: false); + var second = new FakeFilter("deny-all", deny: true); + var third = new FakeFilter("never-reached", deny: true); + + ConnectionFilters.Register(first); + ConnectionFilters.Register(second); + ConnectionFilters.Register(third); + + Assert.True(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); + Assert.Equal("deny-all", deniedBy); + Assert.Equal(1, first.Calls); + Assert.Equal(1, second.Calls); + Assert.Equal(0, third.Calls); // short-circuited + } + + // A filter that throws once throws for every subsequent connection, which would turn one bug into an + // exception per accept. It must be dropped, and the connection must fail open rather than be denied + // by a filter that never actually answered. + [Fact] + public void Throwing_filter_is_unregistered_and_fails_open() + { + var bad = new FakeFilter("bad", deny: true, throws: true); + var good = new FakeFilter("good", deny: false); + + ConnectionFilters.Register(bad); + ConnectionFilters.Register(good); + + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); + Assert.Single(ConnectionFilters.Filters); + Assert.Equal("good", ConnectionFilters.Filters[0].Name); + + // Remaining filters still run on the same pass the faulty one was dropped in. + Assert.Equal(1, good.Calls); + } + + [Fact] + public void Register_configures_immediately() + { + var filter = new FakeFilter("cfg", deny: false); + ConnectionFilters.Register(filter); + + Assert.True(filter.Configured); + } + + private sealed class FakeFilter : IConnectionFilter + { + private readonly bool _deny; + private readonly bool _throws; + + public FakeFilter(string name, bool deny, bool throws = false) + { + Name = name; + _deny = deny; + _throws = throws; + } + + public string Name { get; } + public int Calls { get; private set; } + public bool Configured { get; private set; } + + public void Register() => Configured = true; + + public void Start(CancellationToken token) + { + } + + public void Stop() + { + } + + public bool ShouldDeny(IPAddress address) + { + Calls++; + if (_throws) + { + throw new InvalidOperationException("simulated filter bug"); + } + + return _deny; + } + } +} diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs deleted file mode 100644 index f803ee586..000000000 --- a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs +++ /dev/null @@ -1,137 +0,0 @@ - -using System.Net; -using System.Threading.Tasks; -using Server.Network; -using Xunit; - -namespace Server.Tests; - -public class FirewallTests -{ - [Fact] - public void Firewall_BlocksIPAddress_WhenAdded() - { - var ip = IPAddress.Parse("192.168.1.1"); - var entry = new SingleIpFirewallEntry("192.168.1.1"); - - Assert.False(Firewall.IsBlocked(ip)); - - Firewall.Add(entry); - - Assert.True(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_DoesNotBlockIPAddress_WhenNotAdded() - { - var ip = IPAddress.Parse("192.168.1.2"); - Assert.False(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_StopsBlockingIPAddress_WhenRemoved() - { - var ip = IPAddress.Parse("192.168.1.3"); - var entry = new SingleIpFirewallEntry("192.168.1.3"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_BlocksIPRange() - { - var entry = new CidrFirewallEntry(IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.5")); - - Firewall.Add(entry); - - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.1"))); - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.3"))); - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.5"))); - - Assert.False(Firewall.IsBlocked(IPAddress.Parse("10.0.0.6"))); - } - - [Fact] - public void Firewall_CacheInvalidation_WorksOnUpdate() - { - var ip = IPAddress.Parse("192.168.1.10"); - var entry = new SingleIpFirewallEntry("192.168.1.10"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_ReadsFirewallSetCorrectly() - { - var entry = new SingleIpFirewallEntry("172.16.0.1"); - Firewall.Add(entry); - - var found = false; - Firewall.ReadFirewallSet(set => - { - found = set.Contains(entry); - }); - - Assert.True(found); - } - - [Fact] - public void Firewall_IsThreadSafe() - { - var testIps = new IPAddress[256]; - for (var i = 0; i <= 255; i++) - { - testIps[i] = IPAddress.Parse($"192.168.0.{i}"); - } - - var entry = new CidrFirewallEntry(IPAddress.Parse("192.168.0.1"), IPAddress.Parse("192.168.0.255")); - Firewall.Add(entry); - - Parallel.ForEach(testIps, ip => - { - var shouldBlock = int.Parse(ip.ToString().Split('.')[3]) is > 0; - Assert.Equal(shouldBlock, Firewall.IsBlocked(ip)); - }); - - Firewall.Remove(entry); - - Parallel.ForEach(testIps, ip => - { - Assert.False(Firewall.IsBlocked(ip)); - }); - } - - [Fact] - public void Firewall_DoesNotThrowWhenRemovingNonExistentEntry() - { - var entry = new SingleIpFirewallEntry("203.0.113.5"); - Assert.False(Firewall.Remove(entry)); - } - - [Fact] - public void Firewall_CacheHandlesMultipleUpdates() - { - var ip = IPAddress.Parse("192.168.1.20"); - var entry = new SingleIpFirewallEntry("192.168.1.20"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - } -} diff --git a/Projects/Server/Collections/SortedRangeIndex.cs b/Projects/Server/Collections/SortedRangeIndex.cs new file mode 100644 index 000000000..5af37fd59 --- /dev/null +++ b/Projects/Server/Collections/SortedRangeIndex.cs @@ -0,0 +1,134 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SortedRangeIndex.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.Numerics; + +namespace Server.Collections; + +/// +/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are +/// stored as two parallel arrays (_mins/_maxs) sorted by minimum and coalesced into +/// disjoint runs, so a single binary search decides membership. Coalescing is required for +/// correctness: only inspects the rightmost run whose minimum is <= the +/// value, which is only sound when the runs never overlap or nest. +/// +public sealed class SortedRangeIndex where T : IBinaryInteger +{ + public static readonly SortedRangeIndex Empty = new([], []); + + /// An inclusive [Min, Max] range. Singles are represented as Min == Max. + public readonly record struct Range(T Min, T Max); + + /// Orders ranges ascending by minimum; the coalescing pass in requires this. + public static readonly Comparison ByMin = static (a, b) => a.Min.CompareTo(b.Min); + + private readonly T[] _mins; + private readonly T[] _maxs; + + private SortedRangeIndex(T[] mins, T[] maxs) + { + _mins = mins; + _maxs = maxs; + } + + public int Count => _mins.Length; + + /// + /// True when falls in any range. Binary-searches for the rightmost run + /// whose minimum is <= the value, then tests that value against that run's maximum. + /// + public bool Contains(T value) + { + var lo = 0; + var hi = _mins.Length - 1; + var found = -1; + while (lo <= hi) + { + var mid = (lo + hi) >> 1; + if (_mins[mid] <= value) + { + found = mid; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return found >= 0 && value <= _maxs[found]; + } + + /// + /// Builds an index from ranges that are already sorted ascending by (sort + /// the source with first). Overlapping and nested ranges are merged into disjoint + /// runs. Two passes over the (pooled) input keep the run count exact so only the two final arrays are + /// heap-allocated: pass one counts the runs, pass two fills the exact-size arrays. + /// + public static SortedRangeIndex Build(ReadOnlySpan sortedByMin) + { + if (sortedByMin.IsEmpty) + { + return Empty; + } + + // Pass 1: count the disjoint runs so the final arrays can be sized exactly. + var runs = 1; + var curMax = sortedByMin[0].Max; + for (var i = 1; i < sortedByMin.Length; i++) + { + var r = sortedByMin[i]; + if (r.Min <= curMax) + { + if (r.Max > curMax) + { + curMax = r.Max; + } + } + else + { + runs++; + curMax = r.Max; + } + } + + // Pass 2: write the coalesced runs into the exact-size final arrays. + var mins = new T[runs]; + var maxs = new T[runs]; + var w = 0; + mins[0] = sortedByMin[0].Min; + maxs[0] = sortedByMin[0].Max; + for (var i = 1; i < sortedByMin.Length; i++) + { + var r = sortedByMin[i]; + if (r.Min <= maxs[w]) + { + if (r.Max > maxs[w]) + { + maxs[w] = r.Max; + } + } + else + { + w++; + mins[w] = r.Min; + maxs[w] = r.Max; + } + } + + return new SortedRangeIndex(mins, maxs); + } +} diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index f89fd3084..2be9d7d04 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -1,4 +1,4 @@ -/************************************************************************* +/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * @@ -30,6 +30,7 @@ using Server.Compression; using Server.Json; using Server.Logging; using Server.Network; +using Server.Network.Bans; using Server.Text; namespace Server; @@ -260,7 +261,7 @@ public static class Core // ignored } - if (!close && !Core.Headless) + if (!close && !Headless) { Console.WriteLine("This exception is fatal, press return to exit"); ConsoleInputHandler.ReadLine(); @@ -342,6 +343,8 @@ public static class Core World.ExitSerializationThreads(); PingServer.Shutdown(); NetState.Shutdown(); + BanChannel.Stop(); + ConnectionFilters.Stop(); if (!_crashed) { @@ -461,6 +464,8 @@ public static class Core AssemblyHandler.Invoke("Initialize"); + BanChannel.Start(ClosingTokenSource.Token); + ConnectionFilters.Start(ClosingTokenSource.Token); NetState.Start(); PingServer.Start(); EventSink.InvokeServerStarted(); diff --git a/Projects/Server/Network/Bans/BanChannel.cs b/Projects/Server/Network/Bans/BanChannel.cs new file mode 100644 index 000000000..6d4ead9b1 --- /dev/null +++ b/Projects/Server/Network/Bans/BanChannel.cs @@ -0,0 +1,143 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanChannel.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; +using System.Net; +using System.Threading; +using Server.Logging; + +namespace Server.Network.Bans; + +/// +/// Coordinates the configured contribution sinks. Enforcement is NOT here — +/// the accept path asks . This channel only fans locally-decided bans out +/// to external systems (CrowdSec), which distribute them to OS-level bouncers. +/// +public static class BanChannel +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel)); + + private static IBanReporter[] _reporters = []; + + public static IReadOnlyList Reporters => _reporters; + + /// + /// Registers a contribution sink from content (inversion of control). Idempotent by + /// : a second registration of the same name is ignored. Configures the + /// reporter immediately so it is ready before . + /// + public static void Register(IBanReporter reporter) + { + if (reporter == null) + { + return; + } + + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + if (reporters[i].Name == reporter.Name) + { + return; + } + } + + reporter.Register(); + + var updated = new IBanReporter[_reporters.Length + 1]; + Array.Copy(_reporters, updated, _reporters.Length); + updated[^1] = reporter; + _reporters = updated; + + logger.Information("Ban channel registered reporter '{Name}'", reporter.Name); + } + + internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? []; + + public static void Start(CancellationToken token) + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + var reporter = reporters[i]; + try + { + reporter.Start(token); + } + catch (Exception e) + { + // A broken contribution path must not crash boot — enforcement is local and unaffected. + logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name); + } + } + } + + public static void Stop() + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + var reporter = reporters[i]; + try + { + reporter.Stop(); + } + catch (Exception e) + { + logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name); + } + } + } + + /// Fans a locally-decided ban out to every reporter. Non-blocking; never throws. + public static void Report(IPAddress ip, TimeSpan ttl, string reason) + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + try + { + reporters[i].Report(ip, ttl, reason); + } + catch (Exception e) + { + logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name); + } + } + } + + /// Fans a retraction (manual unban) out to every retract-capable reporter. + public static void Retract(IPAddress ip) + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + if (!reporters[i].CanRetract) + { + continue; + } + + try + { + reporters[i].Retract(ip); + } + catch (Exception e) + { + logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name); + } + } + } +} diff --git a/Projects/Server/Network/Bans/BanConfiguration.cs b/Projects/Server/Network/Bans/BanConfiguration.cs new file mode 100644 index 000000000..2a2af40cf --- /dev/null +++ b/Projects/Server/Network/Bans/BanConfiguration.cs @@ -0,0 +1,76 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanConfiguration.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.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/bans.json (matching the per-feature +/// JSON config pattern used by AssistantConfiguration). Loaded once; a missing file writes a +/// local-only, fail-open template so operators have something to edit. +/// +public static class BanConfiguration +{ + private const string _path = "Configuration/bans.json"; + + public static BanSettings Settings { get; private set; } + + public static void Configure() + { + // Idempotent: a second call must not re-deserialize or overwrite an operator's edits. + if (Settings != null) + { + return; + } + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new BanSettings + { + ReportRateLimitTrips = true, + AutoBanDuration = TimeSpan.FromHours(4) + }; + + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// Ban-channel policy: which reporters receive contributions, and how auto-detections are handled. +public record BanSettings +{ + /// Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set. + [JsonPropertyName("reportRateLimitTrips")] + public bool ReportRateLimitTrips { get; set; } = true; + + /// Duration reported for an auto-detected (rate-limit) ban. + [JsonPropertyName("autoBanDuration")] + public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4); +} diff --git a/Projects/Server/Network/Bans/IBanReporter.cs b/Projects/Server/Network/Bans/IBanReporter.cs new file mode 100644 index 000000000..190c6936e --- /dev/null +++ b/Projects/Server/Network/Bans/IBanReporter.cs @@ -0,0 +1,55 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IBanReporter.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.Net; +using System.Threading; + +namespace Server.Network.Bans; + +/// +/// A contribution sink behind . Reporters receive locally-decided bans +/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system +/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path +/// membership query — that is an 's job. +/// +public interface IBanReporter +{ + /// Stable id for logging/config (e.g. crowdsec). + string Name { get; } + + /// Reads configuration. No network or file I/O here. + void Register(); + + /// Starts background delivery. The token is cancelled on shutdown. + void Start(CancellationToken token); + + /// Flushes and tears down background delivery. + void Stop(); + + /// + /// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only + /// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O. + /// + /// or negative = use the reporter's default duration. + /// Short slug (manual, rate-limit) used as the scenario suffix. + void Report(IPAddress address, TimeSpan ttl, string reason); + + /// True if this reporter can retract a previously-reported ban. + bool CanRetract { get; } + + /// Enqueues a retraction (e.g. a manual unban). No-op if unsupported. + void Retract(IPAddress address); +} diff --git a/Projects/Server/Network/ConnectionFilters.cs b/Projects/Server/Network/ConnectionFilters.cs new file mode 100644 index 000000000..c0bb0b2fb --- /dev/null +++ b/Projects/Server/Network/ConnectionFilters.cs @@ -0,0 +1,155 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ConnectionFilters.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; +using System.Net; +using System.Threading; +using Server.Logging; + +namespace Server.Network; + +/// +/// Registry of the gates the accept path consults, and their lifecycle. +/// Filters are registered during the Configure sweep and the backing store is a plain array, so +/// is an indexed loop over a field read — no enumerator, no closure, no +/// allocation. The whole accept path runs on the game loop, so no synchronization is needed. +/// +public static class ConnectionFilters +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters)); + + private static IConnectionFilter[] _filters = []; + + public static IReadOnlyList Filters => _filters; + + /// + /// Registers a gate (inversion of control, mirroring BanChannel.Register). Idempotent by + /// . Filters are consulted in registration order, so register + /// the cheapest and most selective first — core registers the firewall before content is swept. + /// + public static void Register(IConnectionFilter filter) + { + if (filter == null) + { + return; + } + + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + if (filters[i].Name == filter.Name) + { + return; + } + } + + filter.Register(); + + var updated = new IConnectionFilter[_filters.Length + 1]; + Array.Copy(_filters, updated, _filters.Length); + updated[^1] = filter; + _filters = updated; + + logger.Information("Registered connection filter '{Name}'", filter.Name); + } + + /// + /// True when any filter denies the connection. Short-circuits on the first denial; + /// names it for logging. + /// + public static bool ShouldDeny(IPAddress address, out string deniedBy) + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + // A faulty filter must not take down the accept loop for every connection. + try + { + if (filters[i].ShouldDeny(address)) + { + deniedBy = filters[i].Name; + return true; + } + } + catch (Exception e) + { + Disable(filters[i], e); + } + } + + deniedBy = null; + return false; + } + + /// + /// Drops a filter that threw on the accept path: one that faults once faults for every subsequent + /// connection, costing an exception and a log line per accept. Failing open is deliberate — a broken + /// filter must not be able to deny every connection either. + /// + private static void Disable(IConnectionFilter filter, Exception e) + { + logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name); + + var filters = _filters; + var updated = new List(filters.Length); + for (var i = 0; i < filters.Length; i++) + { + if (!ReferenceEquals(filters[i], filter)) + { + updated.Add(filters[i]); + } + } + + _filters = updated.ToArray(); + } + + public static void Start(CancellationToken token) + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + var filter = filters[i]; + try + { + filter.Start(token); + } + catch (Exception e) + { + // A filter that cannot hydrate must not crash boot; it simply denies nothing. + logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name); + } + } + } + + public static void Stop() + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + var filter = filters[i]; + try + { + filter.Stop(); + } + catch (Exception e) + { + logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name); + } + } + } + + internal static void ResetForTesting() => _filters = []; +} diff --git a/Projects/Server/Network/Firewall/Firewall.cs b/Projects/Server/Network/Firewall/Firewall.cs deleted file mode 100644 index ffb778aa8..000000000 --- a/Projects/Server/Network/Firewall/Firewall.cs +++ /dev/null @@ -1,168 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: Firewall.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.Concurrent; -using System.Collections.Generic; -using System.Net; -using System.Runtime.CompilerServices; -using System.Threading; - -namespace Server.Network; - -public static class Firewall -{ - [ThreadStatic] - private static InternalValidationEntry _validationEntry; - private static readonly ConcurrentDictionary _isBlockedCache = []; - private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion); - - private static int _firewallVersion; - private static readonly SortedSet _firewallSet = []; - - public static int FirewallSetCount => _firewallSet.Count; - - public static void ReadFirewallSet(Action> callback) - { - _firewallLock.EnterReadLock(); - try - { - callback(_firewallSet); - } - finally - { - _firewallLock.ExitReadLock(); - } - } - - internal static bool IsBlocked(IPAddress address) - { - if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion) - { - return true; - } - - if (_validationEntry == null) - { - _validationEntry = new InternalValidationEntry(address); - } - else - { - _validationEntry.Address = address; - } - - if (CheckBlocked(_validationEntry)) - { - _isBlockedCache[address] = _firewallVersion; - return true; - } - - return false; - } - - private static bool CheckBlocked(IFirewallEntry validationEntry) - { - if (_firewallSet.Count == 0) - { - return false; - } - - _firewallLock.EnterReadLock(); - try - { - var min = _firewallSet.Min; - if (validationEntry.CompareTo(min) < 0) - { - return false; - } - - // Get all entries that are lower than our validation entry - var view = _firewallSet.GetViewBetween(min, validationEntry); - - // Loop backward since there shouldn't be any entries where the Min address is higher than ours - foreach (var firewallEntry in view.Reverse()) - { - if (firewallEntry.IsBlocked(validationEntry.MinIpAddress)) - { - return true; - } - } - - return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true; - } - finally - { - _firewallLock.ExitReadLock(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool Add(IFirewallEntry firewallEntry) - { - _firewallLock.EnterWriteLock(); - try - { - if (_firewallSet.Add(firewallEntry)) - { - Interlocked.Increment(ref _firewallVersion); // Update version - return true; - } - return false; - } - finally - { - _firewallLock.ExitWriteLock(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool Remove(IFirewallEntry entry) - { - if (entry == null) - { - return false; - } - - _firewallLock.EnterWriteLock(); - try - { - if (_firewallSet.Remove(entry)) - { - Interlocked.Increment(ref _firewallVersion); // Update version - return true; - } - return false; - } - finally - { - _firewallLock.ExitWriteLock(); - } - } - - private class InternalValidationEntry : BaseFirewallEntry - { - private UInt128 _address; - - public IPAddress Address - { - set => _address = value.ToUInt128(); - } - - public override UInt128 MinIpAddress => _address; - public override UInt128 MaxIpAddress => _address; - - public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress; - } -} diff --git a/Projects/Server/Network/IConnectionFilter.cs b/Projects/Server/Network/IConnectionFilter.cs new file mode 100644 index 000000000..48e971c85 --- /dev/null +++ b/Projects/Server/Network/IConnectionFilter.cs @@ -0,0 +1,60 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IConnectionFilter.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 System.Threading; + +namespace Server.Network; + +/// +/// A gate consulted for every inbound connection, before the socket is configured and before any +/// per-connection allocation. Implementations decide membership only — the accept path neither knows +/// nor cares where a filter's data comes from, so a filter may be a handful of admin-curated entries, +/// a millions-strong list hydrated from a file, or a query against something else entirely. Core owns +/// the question; content owns every answer (see Firewall and BlocklistFilter in UOContent). +/// +/// +/// +/// runs on the game loop once per accepted socket, which is the path that has +/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform +/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service) +/// belongs off the loop or behind a bounded, non-blocking enqueue. +/// +/// +/// Side effects that a hit implies (contributing to BanChannel, promoting to an OS firewall, +/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why +/// returns a bare bool: the accept path asks one question and does one thing. +/// +/// +public interface IConnectionFilter +{ + /// Stable id for logging/config (e.g. firewall, blocklist). + string Name { get; } + + /// Reads configuration. Called by . No I/O beyond config. + void Register(); + + /// Starts any background hydration. The token is cancelled on shutdown. + void Start(CancellationToken token); + + /// Flushes and tears down. Called during shutdown. + void Stop(); + + /// + /// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on + /// . + /// + bool ShouldDeny(IPAddress address); +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 9bfd39673..b38c88d23 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -224,10 +224,19 @@ public partial class NetState if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts)) { logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts); + + if (Bans.BanConfiguration.Settings.ReportRateLimitTrips) + { + // Enqueue-only contribution; NOT added to the local firewall set (the limiter already + // gates it here and the OS bouncer drops it at the kernel). + Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, "rate-limit"); + } } - else if (Firewall.IsBlocked(remoteIP)) + else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy)) { - logger.Debug("{Address} Firewalled", remoteIP); + // Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the + // ban channel) is the filter's own business; the accept path just drops the socket. + logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy); } else { diff --git a/Projects/Server/Utilities/IPAddressUtility.cs b/Projects/Server/Utilities/IPAddressUtility.cs new file mode 100644 index 000000000..12f81e516 --- /dev/null +++ b/Projects/Server/Utilities/IPAddressUtility.cs @@ -0,0 +1,240 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IPAddressUtility.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.Binary; +using System.Net; +using System.Net.Sockets; +using System.Numerics; + +namespace Server; + +/// +/// Low-level IPAddress conversion and parsing helpers shared by the firewall, ban channel, and +/// blocklist. All members are allocation-free (stack buffers only) so they are safe on hot accept +/// paths and inside tight parse loops. +/// +public static class IPAddressUtility +{ + // Converts an IPAddress to a UInt128 in IPv6 format. + // The IsIPv4MappedToIPv6 clause below looks redundant (the BCL only ever sets it on InterNetworkV6), + // but it guards the v4 -> UInt128 -> IPAddress round-trip, which can return a mapped v6 address for + // what is really a v4 one. + //TODO Rework as an explicit "to canonical v6 bits" step that needs no family check + // (see dev-docs/networking-packets.md, "IP Address Normalization") + public static UInt128 ToUInt128(this IPAddress ip) + { + if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) + { + Span integer = stackalloc byte[4]; + return !ip.TryWriteBytes(integer, out _) + ? (UInt128)0 + : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); + } + + Span bytes = stackalloc byte[16]; + if (!ip.TryWriteBytes(bytes, out _)) + { + return 0; + } + + var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); + var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); + + return new UInt128(high, low); + } + + // Converts a UInt128 in IPv6 format to an IPAddress + public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) + { + // IPv4 mapped IPv6 address + if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) + { + var newAddress = IPAddress.HostToNetworkOrder((int)value); + return new IPAddress(unchecked((uint)newAddress)); + } + + Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address + ((IBinaryInteger)value).WriteBigEndian(bytes); + + return new IPAddress(bytes); + } + + /// + /// Parses a.b.c.d/n, ::/n, or a bare address (treated as a single-host range) into an + /// inclusive range in normalized IPv6 form. A bare IPv4 prefix is widened by 96 + /// bits so v4 and v6 ranges are directly comparable. Returns false on anything malformed. + /// + public static bool TryParseCidrRange(ReadOnlySpan cidr, out UInt128 min, out UInt128 max) + { + min = default; + max = default; + + var slash = cidr.IndexOf('/'); + if (!IPAddress.TryParse(slash >= 0 ? cidr[..slash] : cidr, out var ip)) + { + return false; + } + + var isV6 = ip.AddressFamily == AddressFamily.InterNetworkV6; + var maxPrefixLength = isV6 ? 128 : 32; + int prefixLength; + + if (slash < 0) + { + prefixLength = maxPrefixLength; + } + else if (!int.TryParse(cidr[(slash + 1)..], out prefixLength) || + prefixLength < 0 || prefixLength > maxPrefixLength) + { + return false; + } + + if (!isV6) + { + prefixLength += 96; // 32 -> 128 + } + + Span bytes = stackalloc byte[16]; + ip.WriteMappedIPv6To(bytes); + + min = Utility.CreateCidrAddress(bytes, prefixLength, false); + max = Utility.CreateCidrAddress(bytes, prefixLength, true); + return true; + } + + /// Extracts the big-endian uint of an address. + public static bool TryV4(IPAddress ip, out uint v) + { + Span b = stackalloc byte[4]; + if (ip.TryWriteBytes(b, out var n) && n == 4) + { + v = ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; + return true; + } + + v = 0; + return false; + } + + /// + /// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes, + /// avoiding the allocation of . + /// + public static bool TryMappedV4(IPAddress ip, out uint v) + { + Span b = stackalloc byte[16]; + if (ip.TryWriteBytes(b, out var n) && n == 16) + { + v = ((uint)b[12] << 24) | ((uint)b[13] << 16) | ((uint)b[14] << 8) | b[15]; + return true; + } + + v = 0; + return false; + } + + /// Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict. + public static bool TryParseV4(ReadOnlySpan s, out uint v) + { + v = 0; + uint acc = 0; + int octet = 0, digits = 0, dots = 0; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (c == '.') + { + if (digits == 0 || octet > 255) + { + return false; + } + + acc = (acc << 8) | (uint)octet; + dots++; + octet = 0; + digits = 0; + } + else if (c is >= '0' and <= '9') + { + octet = octet * 10 + (c - '0'); + if (++digits > 3) + { + return false; + } + } + else + { + return false; + } + } + + if (dots != 3 || digits == 0 || octet > 255) + { + return false; + } + + v = (acc << 8) | (uint)octet; + return true; + } + + /// + /// UTF-8/ASCII byte overload of , mirroring its + /// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no + /// per-line string allocation. + /// + public static bool TryParseV4(ReadOnlySpan s, out uint v) + { + v = 0; + uint acc = 0; + int octet = 0, digits = 0, dots = 0; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (c == (byte)'.') + { + if (digits == 0 || octet > 255) + { + return false; + } + + acc = (acc << 8) | (uint)octet; + dots++; + octet = 0; + digits = 0; + } + else if (c is >= (byte)'0' and <= (byte)'9') + { + octet = octet * 10 + (c - '0'); + if (++digits > 3) + { + return false; + } + } + else + { + return false; + } + } + + if (dots != 3 || digits == 0 || octet > 255) + { + return false; + } + + v = (acc << 8) | (uint)octet; + return true; + } +} diff --git a/Projects/Server/Utilities/NetworkUtilities.cs b/Projects/Server/Utilities/NetworkUtilities.cs index 7d54ddf5e..deeffb66f 100644 --- a/Projects/Server/Utilities/NetworkUtilities.cs +++ b/Projects/Server/Utilities/NetworkUtilities.cs @@ -1,6 +1,7 @@ +using System; using System.Net; using System.Net.Sockets; -using Server.Network; +using Server.Collections; namespace Server; @@ -14,36 +15,42 @@ public static class NetworkUtilities _ => false }; - private static readonly IFirewallEntry[] _privateNetworkV4 = - [ - new CidrFirewallEntry("127.0.0.1/8"), - new CidrFirewallEntry("192.168.0.0/16"), - new CidrFirewallEntry("10.0.0.0/8"), - new CidrFirewallEntry("172.16.0.0/12"), - new CidrFirewallEntry("169.254.0.0/16"), - new CidrFirewallEntry("100.64.0.0/10") - ]; + // These are constant reserved ranges, not firewall entries -- they only ever answer "is this address + // in one of these blocks?", which is exactly what SortedRangeIndex is for. Building them through the + // firewall entry types was a convenience that made core depend on the firewall for something that has + // nothing to do with banning. + private static readonly SortedRangeIndex _privateNetworkV4 = BuildIndex( + "127.0.0.1/8", + "192.168.0.0/16", + "10.0.0.0/8", + "172.16.0.0/12", + "169.254.0.0/16", + "100.64.0.0/10" + ); - private static readonly IFirewallEntry[] _privateNetworkV6 = - [ - new CidrFirewallEntry("fc00::/7"), - new CidrFirewallEntry("fe80::/10") - ]; + private static readonly SortedRangeIndex _privateNetworkV6 = BuildIndex( + "fc00::/7", + "fe80::/10" + ); - public static bool IsPrivateNetworkV4(this IPAddress ip) + private static SortedRangeIndex BuildIndex(params ReadOnlySpan cidrs) { - for (var i = 0; i < _privateNetworkV4.Length; i++) + var ranges = new SortedRangeIndex.Range[cidrs.Length]; + for (var i = 0; i < cidrs.Length; i++) { - if (_privateNetworkV4[i].IsBlocked(ip)) + if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max)) { - return true; + throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\""); } + + ranges[i] = new SortedRangeIndex.Range(min, max); } - return false; + Array.Sort(ranges, SortedRangeIndex.ByMin); + return SortedRangeIndex.Build(ranges); } - public static bool IsPrivateNetworkV6(this IPAddress ip) => - _privateNetworkV6[0].IsBlocked(ip) || - _privateNetworkV6[1].IsBlocked(ip); + public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128()); + + public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128()); } diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 4f33220aa..f3786d834 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -108,45 +108,6 @@ public static partial class Utility } } - // Converts an IPAddress to a UInt128 in IPv6 format - public static UInt128 ToUInt128(this IPAddress ip) - { - if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) - { - Span integer = stackalloc byte[4]; - return !ip.TryWriteBytes(integer, out _) - ? (UInt128)0 - : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); - } - - Span bytes = stackalloc byte[16]; - if (!ip.TryWriteBytes(bytes, out _)) - { - return 0; - } - - var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); - var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); - - return new UInt128(high, low); - } - - // Converts a UInt128 in IPv6 format to an IPAddress - public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) - { - // IPv4 mapped IPv6 address - if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) - { - var newAddress = IPAddress.HostToNetworkOrder((int)value); - return new IPAddress(unchecked((uint)newAddress)); - } - - Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address - ((IBinaryInteger)value).WriteBigEndian(bytes); - - return new IPAddress(bytes); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static UInt128 CreateCidrAddress(ReadOnlySpan bytes, int prefixLength, bool isMax) { diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 076f88141..640c05837 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Reflection; using System.Threading; @@ -61,6 +61,15 @@ internal static class TestServerInitializer AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); SkillsInfo.Configure(); + + // Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is + // DateTime.MinValue for the whole test host. + Core._now = DateTime.UtcNow; + + // Timer wheel must exist before NetState.Configure(), which schedules a recurring + // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs + // before AssemblyHandler.Invoke("Configure")). + Timer.Init(0); Server.Network.NetState.Configure(); TestMapDefinitions.ConfigureTestMapDefinitions(); @@ -91,7 +100,6 @@ internal static class TestServerInitializer } World.Configure(); - Timer.Init(0); RaceDefinitions.Configure(); MovementImpl.Configure(); PathFollower.Configure(); diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs new file mode 100644 index 000000000..6464a1bed --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -0,0 +1,65 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistConfigurationTests.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.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +public class BlocklistConfigurationTests +{ + // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings + // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. + [Fact] + public void BlocklistSettings_RoundTripsThroughJsonConfig() + { + var original = new BlocklistSettings + { + File = "D:/shared/ip-blocklist.txt", + ReloadInterval = TimeSpan.FromMinutes(5), + ReportHits = false, + BanDuration = TimeSpan.FromHours(2), + PromoteSuppression = TimeSpan.FromSeconds(30) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"file\"", json); + Assert.Contains("\"reloadInterval\"", json); + Assert.Contains("\"reportHits\"", json); + Assert.Contains("\"banDuration\"", json); + Assert.Contains("\"promoteSuppression\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.File, restored.File); + Assert.Equal(original.ReloadInterval, restored.ReloadInterval); + Assert.Equal(original.ReportHits, restored.ReportHits); + Assert.Equal(original.BanDuration, restored.BanDuration); + Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); + } + + // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves + // without the other, a shard silently enforces nothing. + [Fact] + public void Default_file_matches_the_generator_output_path() + { + Assert.Equal("Configuration/ip-blocklist.txt", new BlocklistSettings().File); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs new file mode 100644 index 000000000..1b7a7772c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs @@ -0,0 +1,85 @@ +/************************************************************************* + * 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; +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"))); + } + + // Pins the exact line tools/Export-IpBlocklist.ps1 emits: the producer adds informational tokens the + // reader doesn't know about, and the reload detector breaks silently if generated= stops being read. + [Fact] + public void Reads_generator_header_with_extra_tokens() + { + var p = WriteTemp( + "# modernuo-blocklist generated=2026-07-25T18:03:11Z count=12442 ipv4=7868 cidr=4574 feeds=2\n" + + "2.181.183.77\n223.169.0.0/16\n" + ); + + Assert.True(BlocklistFile.TryReadHeader(p, out var h)); + Assert.Equal("2026-07-25T18:03:11Z", h.Generated); + Assert.Equal(12442, h.Count); + + var snap = BlocklistFile.Load(p, out var parsed, out var skipped); + Assert.Equal(2, parsed); + Assert.Equal(0, skipped); + Assert.True(snap.IsBanned(IPAddress.Parse("2.181.183.77"))); + Assert.True(snap.IsBanned(IPAddress.Parse("223.169.4.9"))); + File.Delete(p); + } + + [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/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs new file mode 100644 index 000000000..4f3ee5ad1 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs @@ -0,0 +1,94 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFilterTests.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 System.Text; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +// No [Collection] and no static reset hook: the filter is an instance, so each test owns its own +// snapshot and promote-guard. That is the point of it no longer being a static class. +public class BlocklistFilterTests +{ + private static BlocklistFilter WithList(string list, bool reportHits = true, long suppressionMs = 5000) + { + var filter = new BlocklistFilter(); + filter.LoadForTesting( + BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(list), out _, out _), + reportHits, + suppressionMs + ); + + return filter; + } + + [Fact] + public void Listed_address_is_denied_and_reported_once_per_window() + { + var filter = WithList("1.2.3.4"); + var ip = IPAddress.Parse("1.2.3.4"); + + var deny1 = filter.Evaluate(ip, 1000, out var report1); + var deny2 = filter.Evaluate(ip, 1500, out var report2); + var deny3 = filter.Evaluate(ip, 1000 + 5001, out var report3); + + Assert.True(deny1); + Assert.True(report1); + + Assert.True(deny2); + Assert.False(report2); // denied again, but promotion suppressed inside the window + + Assert.True(deny3); + Assert.True(report3); // window elapsed, promotion may be retried + } + + [Fact] + public void Unlisted_address_passes() + { + var filter = WithList("1.2.3.4"); + + Assert.False(filter.Evaluate(IPAddress.Parse("9.9.9.9"), 1, out var report)); + Assert.False(report); + } + + [Fact] + public void Cidr_membership_is_honored() + { + var filter = WithList("10.20.30.0/24"); + + Assert.True(filter.Evaluate(IPAddress.Parse("10.20.30.255"), 1, out _)); + Assert.False(filter.Evaluate(IPAddress.Parse("10.20.31.0"), 1, out _)); + } + + [Fact] + public void ReportHits_disabled_still_denies_but_never_promotes() + { + var filter = WithList("1.2.3.4", reportHits: false); + + Assert.True(filter.Evaluate(IPAddress.Parse("1.2.3.4"), 1, out var report)); + Assert.False(report); + } + + [Fact] + public void Unconfigured_filter_denies_nothing() + { + var filter = new BlocklistFilter(); + + Assert.Equal(0, filter.Count); + Assert.False(filter.ShouldDeny(IPAddress.Parse("8.8.8.8"))); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs new file mode 100644 index 000000000..abdac1d11 --- /dev/null +++ b/Projects/UOContent.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; +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/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs new file mode 100644 index 000000000..7c19b8d38 --- /dev/null +++ b/Projects/UOContent.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; +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/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs new file mode 100644 index 000000000..f8a990f25 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs @@ -0,0 +1,51 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlertClientTests.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.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecAlertClientTests +{ + [Fact] + public void BuildDeleteQuery_EscapesOrigin() + { + // origin is operator-controlled config (crowdsec.json), so it must be treated as untrusted + // input going into the URL, same as any other interpolated value. + var query = CrowdSecAlertClient.BuildDeleteQuery("modern uo/test&x=1", IPAddress.Parse("1.2.3.4")); + + Assert.Equal("/v1/decisions?origin=modern%20uo%2Ftest%26x%3D1&ip=1.2.3.4", query); + } + + [Fact] + public void BuildDeleteQuery_PlainOrigin_Ipv4() + { + var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("192.168.1.1")); + + Assert.Equal("/v1/decisions?origin=modernuo&ip=192.168.1.1", query); + } + + [Fact] + public void BuildDeleteQuery_Ipv6_IsEscaped() + { + // IPv6 textual form contains ':', which Uri.EscapeDataString percent-encodes like any other + // reserved character — confirms the escaping is applied uniformly, not just for IPv4. + var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("2001:db8::1")); + + Assert.Equal("/v1/decisions?origin=modernuo&ip=2001%3Adb8%3A%3A1", query); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs new file mode 100644 index 000000000..dd331ac6d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs @@ -0,0 +1,82 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecConfigurationTests.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.Text.Json; +using Server.Json; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecConfigurationTests +{ + // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so + // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently + // binds nothing. These tests round-trip through the exact options the loader uses. + + [Fact] + public void CrowdSecSettings_RoundTripsThroughJsonConfig() + { + var original = new CrowdSecSettings + { + LapiUrl = "http://10.0.0.5:9090", + MachineId = "shard", + Password = "secret", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168), + FlushInterval = TimeSpan.FromSeconds(1), + MaxQueue = 10000 + }; + + var json = JsonConfig.Serialize(original); + + // camelCase property names must be present (not PascalCase) or the case-sensitive reader binds nothing. + Assert.Contains("\"lapiUrl\"", json); + Assert.Contains("\"machineId\"", json); + Assert.Contains("\"password\"", json); + Assert.Contains("\"origin\"", json); + Assert.Contains("\"manualBanDuration\"", json); + Assert.Contains("\"flushInterval\"", json); + Assert.Contains("\"maxQueue\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.LapiUrl, restored.LapiUrl); + Assert.Equal(original.MachineId, restored.MachineId); + Assert.Equal(original.Password, restored.Password); + Assert.Equal(original.Origin, restored.Origin); + Assert.Equal(original.ManualBanDuration, restored.ManualBanDuration); // TimeSpan survives + Assert.Equal(original.FlushInterval, restored.FlushInterval); // TimeSpan survives + Assert.Equal(original.MaxQueue, restored.MaxQueue); + Assert.True(restored.ReportingEnabled); + } + + [Fact] + public void CrowdSecSettings_Defaults_AreReportingDisabledLocalLoopback() + { + var settings = new CrowdSecSettings(); + + Assert.Equal("http://127.0.0.1:8080", settings.LapiUrl); + Assert.Equal("", settings.MachineId); + Assert.Equal("", settings.Password); + Assert.Equal("modernuo", settings.Origin); + Assert.Equal(TimeSpan.FromHours(168), settings.ManualBanDuration); + Assert.Equal(TimeSpan.FromSeconds(1), settings.FlushInterval); + Assert.Equal(10000, settings.MaxQueue); + Assert.False(settings.ReportingEnabled); // empty machineId/password => inert + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs new file mode 100644 index 000000000..a8daf3c44 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -0,0 +1,229 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecReporterTests.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; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecReporterTests +{ + private static CrowdSecSettings Settings() => new() + { + MachineId = "shard", + Password = "secret", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168) + }; + + [Fact] + public void BuildAlerts_DedupsByIp() + { + var now = DateTime.UnixEpoch; + var items = new List + { + new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), + new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), + new(IPAddress.Parse("2.2.2.2"), TimeSpan.FromHours(1), "rate-limit", false) + }; + + var alerts = CrowdSecReporter.BuildAlerts(items, Settings(), now); + + Assert.Equal(2, alerts.Count); + Assert.All(alerts, a => Assert.Single(a.Decisions)); + Assert.Contains(alerts, a => a.Source.Value == "1.1.1.1"); + Assert.Contains(alerts, a => a.Source.Value == "2.2.2.2"); + } + + [Fact] + public void BuildAlerts_ScenarioFromReason_OriginFromSettings() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("3.3.3.3"), TimeSpan.FromHours(1), "manual", false)], + Settings(), + DateTime.UnixEpoch); + + var decision = Assert.Single(alerts).Decisions[0]; + Assert.Equal("modernuo/manual", alerts[0].Scenario); + Assert.Equal("modernuo", decision.Origin); + Assert.Equal("ban", decision.Type); + Assert.Equal("Ip", decision.Scope); + Assert.Equal("3.3.3.3", decision.Value); + } + + [Fact] + public void BuildAlerts_ScenarioFromReason_Blocklist() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("5.5.5.5"), TimeSpan.FromHours(1), "blocklist", false)], + Settings(), + DateTime.UnixEpoch); + + var decision = Assert.Single(alerts).Decisions[0]; + Assert.Equal("modernuo/blocklist", alerts[0].Scenario); + Assert.Equal("modernuo/blocklist", decision.Scenario); + } + + [Fact] + public void FormatDuration_UsesSeconds_FloorsAtOne() + { + Assert.Equal("3600s", CrowdSecReporter.FormatDuration(TimeSpan.FromHours(1))); + Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.Zero)); + Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.FromMilliseconds(10))); + } + + [Fact] + public void Report_WhenQueueFull_DropsAndCounts() + { + var reporter = new CrowdSecReporter(new NullAlertClient(), new CrowdSecSettings + { + MachineId = "shard", + Password = "secret", + MaxQueue = 2 + }); + // Do NOT Start() the drain — so the queue fills and overflows deterministically. + + for (var i = 0; i < 10; i++) + { + reporter.Report(IPAddress.Parse("4.4.4." + i), TimeSpan.FromHours(1), "rate-limit"); + } + + Assert.True(reporter.DroppedCount >= 8); + } + + // Stop() without a prior Start() drives FlushRemainingOnStop() synchronously (no drain task, no + // Task.Delay backoff involved), so this is deterministic — no wall-clock timing dependency. + [Fact] + public void Stop_FlushesQueuedReports_ViaClient() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Report(IPAddress.Parse("6.6.6.6"), TimeSpan.FromHours(1), "rate-limit"); + reporter.Stop(); + + var posted = Assert.Single(client.Posted); + Assert.Equal("6.6.6.6", Assert.Single(posted).Source.Value); + Assert.Equal(0, reporter.SendFailureCount); + } + + [Fact] + public void Stop_FlushesQueuedRetracts_ViaClient() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Retract(IPAddress.Parse("8.8.8.8")); + reporter.Stop(); + + Assert.Equal(IPAddress.Parse("8.8.8.8"), Assert.Single(client.Deleted)); + Assert.Equal(0, reporter.SendFailureCount); + } + + [Fact] + public void Stop_WhenFlushSendFails_CountsSendFailure() + { + var reporter = new CrowdSecReporter(new ThrowingAlertClient(), Settings()); + + reporter.Report(IPAddress.Parse("7.7.7.7"), TimeSpan.FromHours(1), "rate-limit"); + reporter.Stop(); + + Assert.Equal(1, reporter.SendFailureCount); + } + + [Fact] + public void Stop_WithEmptyQueue_DoesNotInvokeClientOrFail() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Stop(); + + Assert.Empty(client.Posted); + Assert.Equal(0, reporter.SendFailureCount); + } + + /// + /// The drain task must track the loop's lifetime, not just its first await — a ValueTask-returning + /// drain loop passed to Task.Run yields a Task<ValueTask> that completes immediately, which makes + /// Stop()'s drain-exited handshake a no-op. With an empty queue the loop parks on WaitToReadAsync, + /// so a correctly unwrapped task cannot win this race; a slow pool only under-detects. + /// + [Fact] + public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait() + { + var reporter = new CrowdSecReporter(new NullAlertClient(), Settings()); + using var cts = new CancellationTokenSource(); + + reporter.Start(cts.Token); + + var drain = reporter.DrainTaskForTesting; + Assert.NotNull(drain); + + var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500))); + Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running"); + + reporter.Stop(); + + Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited"); + } + + private sealed class NullAlertClient : ICrowdSecAlertClient + { + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => + ValueTask.CompletedTask; + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } + + private sealed class RecordingAlertClient : ICrowdSecAlertClient + { + public List> Posted { get; } = []; + public List Deleted { get; } = []; + + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + Posted.Add(alerts); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) + { + Deleted.Add(ip); + return ValueTask.CompletedTask; + } + + public void Dispose() { } + } + + private sealed class ThrowingAlertClient : ICrowdSecAlertClient + { + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => + throw new InvalidOperationException("simulated LAPI outage"); + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } +} diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs similarity index 100% rename from Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs rename to Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs new file mode 100644 index 000000000..01691e223 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs @@ -0,0 +1,72 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallPersistenceTests.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.Net; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network.Firewall; + +[Collection("Sequential UOContent Tests")] +public class FirewallPersistenceTests +{ + [Fact] + public void ToSettings_RoundTrips_PermanentAndTtl() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("2.2.2.2")), TimeSpan.FromHours(1)); + + var settings = Server.Network.Firewall.ToSettings(); + + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.LoadFrom(settings); + + Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("1.2.3.4"))); + Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("2.2.2.2"))); + } + + [Fact] + public void LoadFrom_SkipsAlreadyExpired() + { + Server.Network.Firewall.ResetForTesting(); + var settings = new FirewallSettings + { + Entries = + [ + // Core.Now, matching the clock LoadFrom compares against. + new FirewallEntryRecord { Value = "9.9.9.9", Expires = Core.Now.AddHours(-1) } + ] + }; + + Server.Network.Firewall.LoadFrom(settings); + + Assert.False(Server.Network.Firewall.IsBlocked(IPAddress.Parse("9.9.9.9"))); + } + + [Fact] + public void ToSettings_OmitsExpiryForPermanent() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); + + var settings = Server.Network.Firewall.ToSettings(); + + Assert.Single(settings.Entries); + Assert.Null(settings.Entries[0].Expires); + Assert.Equal("1.2.3.4", settings.Entries[0].Value); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs new file mode 100644 index 000000000..fabf6d4ba --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -0,0 +1,89 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallTests.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.Net; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network.Firewall; + +[Collection("Sequential UOContent Tests")] +public class FirewallTests +{ + private static IPAddress Ip(string s) => IPAddress.Parse(s); + + [Fact] + public void Add_ThenIsBlocked_SingleIp() + { + Server.Network.Firewall.ResetForTesting(); + Assert.True(Server.Network.Firewall.Add(new SingleIpFirewallEntry(Ip("1.2.3.4")))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.5"))); + } + + [Fact] + public void Add_Range_BlocksInside_NotOutside() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new CidrFirewallEntry(Ip("10.0.0.0"), Ip("10.0.0.255"))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("10.0.0.7"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("10.0.1.0"))); + } + + [Fact] + public void Remove_Unblocks() + { + Server.Network.Firewall.ResetForTesting(); + var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); + Server.Network.Firewall.Add(entry); + Assert.True(Server.Network.Firewall.Remove(entry)); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); + } + + [Fact] + public void ExpireEntries_RemovesExpired_KeepsPermanent() + { + Server.Network.Firewall.ResetForTesting(); + var permanent = new SingleIpFirewallEntry(Ip("1.1.1.1")); + var temporary = new SingleIpFirewallEntry(Ip("2.2.2.2")); + Server.Network.Firewall.Add(permanent); // no ttl + Server.Network.Firewall.Add(temporary, TimeSpan.FromMilliseconds(50)); // ttl + + Server.Network.Firewall.ExpireEntries(Core.TickCount + 100); // past the ttl + + Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.1.1.1"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("2.2.2.2"))); + } + + [Fact] + public void ToFirewallEntry_ParsesForms() + { + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("1.2.3.4")); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0/24")); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0-10.0.0.255")); + Assert.Null(Server.Network.Firewall.ToFirewallEntry("not-an-ip")); + } + + [Fact] + public void ReadFirewallSet_SurfacesAddedEntries() + { + Server.Network.Firewall.ResetForTesting(); + var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); + Server.Network.Firewall.Add(entry); + + Server.Network.Firewall.ReadFirewallSet(set => Assert.Contains(entry, set)); + } +} diff --git a/Projects/UOContent/Commands/Generic/Commands/Commands.cs b/Projects/UOContent/Commands/Generic/Commands/Commands.cs index 81d85fc0a..b3fa95350 100644 --- a/Projects/UOContent/Commands/Generic/Commands/Commands.cs +++ b/Projects/UOContent/Commands/Generic/Commands/Commands.cs @@ -9,6 +9,7 @@ using Server.Items; using Server.Mobiles; using Server.Multis; using Server.Network; +using Server.Network.Bans; using Server.Spells; namespace Server.Commands.Generic @@ -1154,7 +1155,8 @@ namespace Server.Commands.Generic try { - AdminFirewall.Add(state.Address); + Firewall.Add(new SingleIpFirewallEntry(state.Address)); + BanChannel.Report(state.Address, TimeSpan.Zero, "manual"); AddResponse("They have been firewalled."); } catch (Exception ex) diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 6e7c3ac23..1e788372e 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -11,6 +11,7 @@ using Server.Maps; using Server.Misc; using Server.Multis; using Server.Network; +using Server.Network.Bans; using Server.Prompts; using Server.Saves; using Server.Text; @@ -1743,7 +1744,8 @@ namespace Server.Gumps { for (var i = 0; i < a.LoginIPs.Length; ++i) { - AdminFirewall.Add(a.LoginIPs[i]); + Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i])); + BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, "manual"); } notice = "All addresses in the list have been firewalled."; @@ -1767,7 +1769,13 @@ namespace Server.Gumps if (okay) { - AdminFirewall.Add(toFirewall); + var firewallEntry = Firewall.ToFirewallEntry(toFirewall); + Firewall.Add(firewallEntry); + + if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) + { + BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, "manual"); + } notice = $"{toFirewall} : Added to firewall."; } @@ -3559,7 +3567,7 @@ namespace Server.Gumps IFirewallEntry firewallEntry; try { - firewallEntry = AdminFirewall.ToFirewallEntry(text); + firewallEntry = Firewall.ToFirewallEntry(text); } catch { @@ -3581,7 +3589,17 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} firewalling {firewallEntry}" ); - AdminFirewall.Add(firewallEntry); + Firewall.Add(firewallEntry); + + if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) + { + BanChannel.Report( + firewallEntry.MinIpAddress.ToIpAddress(), + TimeSpan.Zero, + "manual" + ); + } + from.SendGump( new AdminGump( from, @@ -3620,7 +3638,13 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} removing {m_State} from firewall list" ); - AdminFirewall.Remove(m_State); + Firewall.Remove(m_State as IFirewallEntry); + + if (m_State is IFirewallEntry fe && fe.MinIpAddress == fe.MaxIpAddress) + { + BanChannel.Retract(fe.MinIpAddress.ToIpAddress()); + } + from.SendGump( new AdminGump( from, diff --git a/Projects/UOContent/Misc/AdminFirewall.cs b/Projects/UOContent/Misc/AdminFirewall.cs deleted file mode 100644 index a4275d90c..000000000 --- a/Projects/UOContent/Misc/AdminFirewall.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.Net; -using System.Runtime.CompilerServices; -using Server.Logging; -using Server.Network; - -namespace Server; - -public static class AdminFirewall -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(AdminFirewall)); - - private const string firewallConfigPath = "firewall.cfg"; - - public static void Configure() - { - if (File.Exists(firewallConfigPath)) - { - var searchValues = SearchValues.Create("*Xx?"); - - using var ip = new StreamReader(firewallConfigPath); - - while (ip.ReadLine() is { } line) - { - line = line.Trim(); - - if (line.Length == 0) - { - continue; - } - - if (line.AsSpan().ContainsAny(searchValues)) - { - logger.Warning("Legacy firewall entry \"{Entry}\" ignored", line); - continue; - } - - Add(ToFirewallEntry(line), false); - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IFirewallEntry ToFirewallEntry(object entry) - { - return entry switch - { - IFirewallEntry firewallEntry => firewallEntry, - IPAddress address => new SingleIpFirewallEntry(address), - string s => ToFirewallEntry(s), - _ => null - }; - } - - public static IFirewallEntry ToFirewallEntry(string entry) - { - if (entry == null) - { - return null; - } - - try - { - var rangeSeparator = entry.IndexOf('-'); - if (rangeSeparator > -1) - { - return new CidrFirewallEntry( - IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), - IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) - ); - } - - // CIDR notation - if (entry.IndexOf('/') > -1) - { - return new CidrFirewallEntry(entry); - } - - return new SingleIpFirewallEntry(entry); - } - catch - { - return null; - } - } - - public static bool Remove(object obj, bool save = true) - { - var entry = ToFirewallEntry(obj); - - if (entry == null) - { - return false; - } - - if (!Firewall.Remove(entry)) - { - return false; - } - - if (save) - { - Save(); - } - - return true; - } - - public static void Add(object obj) => Add(ToFirewallEntry(obj)); - - public static bool Add(IFirewallEntry entry, bool save = true) - { - if (!Firewall.Add(entry)) - { - return false; - } - - if (save) - { - Save(); - } - - return true; - } - - public static void Save() - { - Firewall.ReadFirewallSet(firewallSet => - { - using var op = new StreamWriter(firewallConfigPath); - foreach (var entry in firewallSet) - { - op.WriteLine(entry); - } - }); - } -} diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs new file mode 100644 index 000000000..ca39bd53e --- /dev/null +++ b/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs @@ -0,0 +1,90 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistConfiguration.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.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/blocklist.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. +/// +public static class BlocklistConfiguration +{ + private const string _path = "Configuration/blocklist.json"; + + public static BlocklistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new BlocklistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . The filter is inert unless +/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs +/// the generator. +/// +public record BlocklistSettings +{ + /// + /// Path to the blocklist. A relative path resolves against ; an + /// absolute path is used as-is (handy when several shards share one generated list). Set to + /// "" to disable the gate entirely. Produce the file with tools/Export-IpBlocklist.ps1. + /// + [JsonPropertyName("file")] + public string File { get; set; } = "Configuration/ip-blocklist.txt"; + + /// How often the file is checked for changes. Reloads only happen when it actually changed. + [JsonPropertyName("reloadInterval")] + public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); + + /// Whether blocklist hits are contributed to the ban channel (the demand-paging promotion). + [JsonPropertyName("reportHits")] + public bool ReportHits { get; set; } = true; + + /// Duration reported for a blocklist-matched ban. + [JsonPropertyName("banDuration")] + public TimeSpan BanDuration { get; set; } = TimeSpan.FromHours(6); + + /// + /// How long the accept-path guard suppresses re-reporting a promoted address. This only needs to + /// bridge the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops + /// repeat traffic. Decoupled from so the guard doesn't have to remember + /// hours' worth of distinct addresses. + /// + [JsonPropertyName("promoteSuppression")] + public TimeSpan PromoteSuppression { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistFile.cs b/Projects/UOContent/Misc/Blocklist/BlocklistFile.cs new file mode 100644 index 000000000..a593a7035 --- /dev/null +++ b/Projects/UOContent/Misc/Blocklist/BlocklistFile.cs @@ -0,0 +1,86 @@ +/************************************************************************* + * 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; + +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('#')) + { + var tokens = first.Split(' ', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < tokens.Length; i++) + { + var tok = tokens[i]; + 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/UOContent/Misc/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs new file mode 100644 index 000000000..aeca58477 --- /dev/null +++ b/Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs @@ -0,0 +1,259 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFilter.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; + +/// +/// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator +/// (tools/Export-IpBlocklist.ps1) writes on a schedule. Holds an immutable snapshot swapped +/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is +/// configured or present. +/// +/// +/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on +/// Windows, so the millions live here and only addresses that actually connect are promoted to CrowdSec +/// (and from there to the OS firewall) through . +/// keeps a flood of repeat connections from re-reporting the same address before the bouncer picks it up. +/// +public sealed class BlocklistFilter : IConnectionFilter +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter)); + + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. + private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; + + private readonly PromotedGuard _guard = new(); + + private string _path; + private TimeSpan _interval; + private bool _reportHits; + private TimeSpan _banDuration; + private long _suppressionMs; + private string _lastGenerated; + private DateTime _lastWriteUtc; + private CancellationTokenSource _cts; + + public string Name => "blocklist"; + + public int Count => _snapshot.Count; + + public static void Configure() + { + ConnectionFilters.Register(new BlocklistFilter()); + } + + public void Register() + { + BlocklistConfiguration.Load(); + var s = BlocklistConfiguration.Settings; + + _path = ResolvePath(s.File); + _interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval; + _reportHits = s.ReportHits; + _banDuration = s.BanDuration; + _suppressionMs = (long)s.PromoteSuppression.TotalMilliseconds; + } + + /// + /// Resolves the configured path once: a relative path is anchored to + /// (never the process working directory, which differs when the shard is launched from elsewhere), an + /// absolute path is taken as-is so several shards can share one generated list. + /// + private static string ResolvePath(string configured) + { + if (string.IsNullOrWhiteSpace(configured)) + { + return null; + } + + return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured); + } + + public void Start(CancellationToken token) + { + if (_path == null) + { + logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); + return; + } + + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + // A missing file is the shipped default, not an error: the gate stays inert until the poll picks + // up whatever the generator first writes. No restart needed. + if (File.Exists(_path)) + { + Reload(); // synchronous prime; empty on failure (fail-open) + } + else + { + logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval); + } + + // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); + + _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); + } + + public void Stop() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + public bool ShouldDeny(IPAddress address) + { + if (!Evaluate(address, Core.TickCount, out var shouldReport)) + { + return false; + } + + if (shouldReport) + { + // Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop. + BanChannel.Report(address, _banDuration, "blocklist"); + } + + return true; + } + + /// + /// The pure decision, split out so the accept-path policy can be tested without a clock or a ban + /// channel. is true at most once per suppression window. + /// + internal bool Evaluate(IPAddress address, long nowTicks, out bool shouldReport) + { + shouldReport = false; + + if (!_snapshot.IsBanned(address)) + { + return false; + } + + if (_reportHits) + { + shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs); + } + + return true; + } + + private void SweepGuard() => _guard.Sweep(Core.TickCount); + + // Test hook: inject a snapshot and policy without file I/O. + internal void LoadForTesting(BlocklistSnapshot snapshot, bool reportHits = true, long suppressionMs = 60000) + { + _snapshot = snapshot; + _reportHits = reportHits; + _suppressionMs = suppressionMs; + } + + private async ValueTask PollLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_interval, token); + } + catch (OperationCanceledException) + { + return; + } + + try + { + if (ChangedSinceLastLoad()) + { + // Parsing millions of lines competes with a save for CPU and page cache, so yield until + // the world is written out. See the threading policy in CLAUDE.md (rules #3 and #10). + while (World.Saving || World.WorldState == WorldState.PendingSave) + { + await Task.Delay(TimeSpan.FromSeconds(1), token); + } + + Reload(); + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + logger.Warning(e, "Blocklist reload check failed; keeping last snapshot ({Count})", Count); + } + } + } + + private 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 void Reload() + { + // Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not + // one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll; + // capturing after could skip a version entirely. + 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("Blocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}", + parsed, next.Count, skipped, h.Generated); + } +} diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs new file mode 100644 index 000000000..52839512c --- /dev/null +++ b/Projects/UOContent/Misc/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; + +/// +/// 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/UOContent/Misc/Blocklist/PromotedGuard.cs b/Projects/UOContent/Misc/Blocklist/PromotedGuard.cs new file mode 100644 index 000000000..4f820b16e --- /dev/null +++ b/Projects/UOContent/Misc/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; + +/// Suppresses re-reporting the same IP within a TTL. Accept-path thread only. +public sealed class PromotedGuard +{ + private readonly Dictionary _expiry = []; + + 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()); + } + } +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs new file mode 100644 index 000000000..db0d8d80f --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs @@ -0,0 +1,65 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlert.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.Text.Json.Serialization; + +namespace Server.Network.Bans.CrowdSec; + +/// One CrowdSec alert (POST /v1/alerts takes an array of these). +public sealed class CrowdSecAlert +{ + [JsonPropertyName("scenario")] public string Scenario { get; set; } + [JsonPropertyName("message")] public string Message { get; set; } + [JsonPropertyName("events_count")] public int EventsCount { get; set; } = 1; + [JsonPropertyName("start_at")] public string StartAt { get; set; } + [JsonPropertyName("stop_at")] public string StopAt { get; set; } + [JsonPropertyName("capacity")] public int Capacity { get; set; } + [JsonPropertyName("leakspeed")] public string LeakSpeed { get; set; } = "0s"; + [JsonPropertyName("simulated")] public bool Simulated { get; set; } + [JsonPropertyName("events")] public object[] Events { get; set; } = []; + [JsonPropertyName("remediation")] public bool Remediation { get; set; } = true; + [JsonPropertyName("source")] public CrowdSecSource Source { get; set; } + [JsonPropertyName("decisions")] public CrowdSecDecisionDto[] Decisions { get; set; } +} + +public sealed class CrowdSecSource +{ + [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; + [JsonPropertyName("value")] public string Value { get; set; } +} + +public sealed class CrowdSecDecisionDto +{ + [JsonPropertyName("origin")] public string Origin { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } = "ban"; + [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; + [JsonPropertyName("value")] public string Value { get; set; } + [JsonPropertyName("duration")] public string Duration { get; set; } + [JsonPropertyName("scenario")] public string Scenario { get; set; } +} + +/// Watcher login request/response for POST /v1/watchers/login. +public sealed class CrowdSecLoginRequest +{ + [JsonPropertyName("machine_id")] public string MachineId { get; set; } + [JsonPropertyName("password")] public string Password { get; set; } +} + +public sealed class CrowdSecLoginResponse +{ + [JsonPropertyName("code")] public int Code { get; set; } + [JsonPropertyName("token")] public string Token { get; set; } + [JsonPropertyName("expire")] public string Expire { get; set; } +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs new file mode 100644 index 000000000..af853244a --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs @@ -0,0 +1,137 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlertClient.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; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Server.Network.Bans.CrowdSec; + +/// Reporter-side LAPI operations, mockable for tests. +public interface ICrowdSecAlertClient : IDisposable +{ + ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token); + ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token); +} + +/// +/// CrowdSec LAPI watcher client: authenticates with machine credentials and posts/deletes decisions. +/// Holds a JWT refreshed on expiry or a 401. +/// +public sealed class CrowdSecAlertClient : ICrowdSecAlertClient +{ + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly HttpClient _http; + private readonly string _machineId; + private readonly string _password; + + private string _token; + private DateTime _tokenExpiresUtc = DateTime.MinValue; + + public CrowdSecAlertClient(CrowdSecSettings settings) + { + var baseUri = new Uri(settings.LapiUrl, UriKind.Absolute); // fails loud on malformed url + _http = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromSeconds(30) }; + _http.DefaultRequestHeaders.Add("User-Agent", "ModernUO-watcher/1.0"); + _machineId = settings.MachineId; + _password = settings.Password; + } + + private async Task EnsureAuthAsync(CancellationToken token) + { + if (_token != null && DateTime.UtcNow < _tokenExpiresUtc - TimeSpan.FromMinutes(1)) + { + return; + } + + var request = new CrowdSecLoginRequest { MachineId = _machineId, Password = _password }; + using var response = await _http.PostAsJsonAsync("/v1/watchers/login", request, _jsonOptions, token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var login = await response.Content.ReadFromJsonAsync(_jsonOptions, token) + .ConfigureAwait(false); + _token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token."); + _tokenExpiresUtc = DateTime.TryParse(login.Expire, out var exp) ? exp.ToUniversalTime() : DateTime.UtcNow.AddHours(1); + } + + private void Authorize(HttpRequestMessage message) => + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + + public async ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + if (alerts.Count == 0) + { + return; + } + + await SendWithRetryAsync(() => + { + var message = new HttpRequestMessage(HttpMethod.Post, "/v1/alerts") + { + Content = JsonContent.Create(alerts, options: _jsonOptions) + }; + Authorize(message); + return message; + }, token).ConfigureAwait(false); + } + + public async ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) + { + var query = BuildDeleteQuery(origin, ip); + await SendWithRetryAsync(() => + { + var message = new HttpRequestMessage(HttpMethod.Delete, query); + Authorize(message); + return message; + }, token).ConfigureAwait(false); + } + + /// + /// Builds the decisions-delete query string. is operator-controlled config + /// (crowdsec.json), so it must be escaped like any other untrusted value going into a URL. + /// + internal static string BuildDeleteQuery(string origin, IPAddress ip) => + $"/v1/decisions?origin={Uri.EscapeDataString(origin)}&ip={Uri.EscapeDataString(ip.ToString())}"; + + private async ValueTask SendWithRetryAsync(Func build, CancellationToken token) + { + await EnsureAuthAsync(token).ConfigureAwait(false); + + using var first = build(); + using var response = await _http.SendAsync(first, token).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.Unauthorized) + { + response.EnsureSuccessStatusCode(); + return; + } + + // Token rejected mid-flight: force a re-login and retry once. + _token = null; + await EnsureAuthAsync(token).ConfigureAwait(false); + using var retry = build(); + using var retryResponse = await _http.SendAsync(retry, token).ConfigureAwait(false); + retryResponse.EnsureSuccessStatusCode(); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs new file mode 100644 index 000000000..b1376b6a0 --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs @@ -0,0 +1,101 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecConfiguration.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.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans.CrowdSec; + +/// +/// Loads the from Configuration/crowdsec.json (matching the +/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file +/// writes a disabled-by-default template so operators have something to edit. +/// +public static class CrowdSecConfiguration +{ + private const string _path = "Configuration/crowdsec.json"; + + public static CrowdSecSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new CrowdSecSettings + { + LapiUrl = "http://127.0.0.1:8080", + MachineId = "", + Password = "", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168), + FlushInterval = TimeSpan.FromSeconds(1), + MaxQueue = 10000 + }; + + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . Read once at Configure(). +/// The reporter is inert unless is true. +/// +public record CrowdSecSettings +{ + /// LAPI endpoint. Default http://127.0.0.1:8080. + [JsonPropertyName("lapiUrl")] + public string LapiUrl { get; set; } = "http://127.0.0.1:8080"; + + /// Watcher machine id from cscli machines add. Empty disables reporting. + [JsonPropertyName("machineId")] + public string MachineId { get; set; } = ""; + + /// Watcher password paired with . + [JsonPropertyName("password")] + public string Password { get; set; } = ""; + + /// Decision origin stamped on our contributions. Default modernuo. + [JsonPropertyName("origin")] + public string Origin { get; set; } = "modernuo"; + + /// Duration for manual admin bans pushed to CrowdSec. Default 168h (renewable). Finite so a missed retract self-heals. + [JsonPropertyName("manualBanDuration")] + public TimeSpan ManualBanDuration { get; set; } = TimeSpan.FromHours(168); + + /// Max time the drain coalesces before flushing a batch. Default 1s. + [JsonPropertyName("flushInterval")] + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// Bounded contribution queue capacity; overflow is dropped (counted). Default 10000. + [JsonPropertyName("maxQueue")] + public int MaxQueue { get; set; } = 10000; + + [JsonIgnore] + public bool ReportingEnabled => !string.IsNullOrWhiteSpace(MachineId) && !string.IsNullOrWhiteSpace(Password); +} diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs new file mode 100644 index 000000000..306d4ab53 --- /dev/null +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs @@ -0,0 +1,399 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecReporter.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; +using System.Net; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Server.Logging; + +namespace Server.Network.Bans.CrowdSec; + +/// +/// Contributes locally-decided bans to CrowdSec via the LAPI alerts API. Non-blocking on the accept +/// path: enqueues onto a bounded, drop-on-overflow channel drained by a single +/// background task that coalesces by IP and POSTs batched alerts. +/// +public sealed class CrowdSecReporter : IBanReporter +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(CrowdSecReporter)); + + internal readonly record struct ReportItem(IPAddress Ip, TimeSpan Ttl, string Reason, bool Retract); + + // Bounded retry for transient LAPI failures during a drain send (network blips, 5xx). Distinct from + // CrowdSecAlertClient.SendWithRetryAsync's single 401-relogin retry, which is an auth concern. + private static readonly TimeSpan[] _retryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)]; + + private ICrowdSecAlertClient _client; + private CrowdSecSettings _settings; + private Channel _queue; + private CancellationTokenSource _cts; + private Task _drainTask; + private int _dropped; + private int _sendFailures; + + public CrowdSecReporter() + { + } + + // Test/embedding ctor with an injected client + settings. + internal CrowdSecReporter(ICrowdSecAlertClient client, CrowdSecSettings settings) + { + _client = client; + _settings = settings; + _queue = CreateQueue(settings.MaxQueue); + } + + public string Name => "crowdsec"; + public bool CanRetract => true; + public int DroppedCount => _dropped; + + /// + /// Batches ultimately dropped after the bounded transient-retry in gave up. + /// Distinct from (queue-overflow drops on the accept path): this counts + /// sustained LAPI outages so operators can see contribution loss instead of it being silent. + /// + public int SendFailureCount => _sendFailures; + + /// The drain loop's task, so tests can assert it stays alive until the loop exits. + internal Task DrainTaskForTesting => _drainTask; + + public static void Configure() + { + BanChannel.Register(new CrowdSecReporter()); + } + + public void Register() + { + CrowdSecConfiguration.Load(); + _settings ??= CrowdSecConfiguration.Settings; + } + + public void Start(CancellationToken token) + { + if (!_settings.ReportingEnabled) + { + logger.Information("CrowdSec reporter disabled (machineId/password empty in crowdsec.json)"); + return; + } + + _client ??= new CrowdSecAlertClient(_settings); + _queue ??= CreateQueue(_settings.MaxQueue); + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + _drainTask = Task.Run(() => DrainLoop(_cts.Token), _cts.Token); + } + + public void Stop() + { + _cts?.Cancel(); + + // The flush below reads a SingleReader channel, so wait for the drain to actually exit first. + var drainExited = true; + try + { + // Wait(timeout) is false only on timeout; a throw means faulted/cancelled, which is still exited. + drainExited = _drainTask == null || _drainTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // Ignored: a faulted wait means the drain has completed and released the channel. + } + + _cts?.Dispose(); + _cts = null; + + if (drainExited) + { + FlushRemainingOnStop(); + } + + _client?.Dispose(); + _drainTask = null; + } + + /// + /// Best-effort bounded flush of whatever is still queued at shutdown. Blocking is correct here — the + /// loop has stopped ticking — but must not happen on the loop thread: runs where + /// SynchronizationContext.Current is the EventLoopContext, and a captured continuation + /// would be posted to a queue nothing pumps any more. keeps the + /// chain on the pool; the bounded wait caps a wedged send at a few seconds of shutdown. + /// + private void FlushRemainingOnStop() + { + if (_queue == null || _client == null) + { + return; + } + + _queue.Writer.TryComplete(); + + List reports = []; + List retracts = []; + while (_queue.Reader.TryRead(out var item)) + { + (item.Retract ? retracts : reports).Add(item); + } + + if (reports.Count == 0 && retracts.Count == 0) + { + return; + } + + try + { + if (!Task.Run(() => FlushRemainingOnStopAsync(reports, retracts)).Wait(TimeSpan.FromSeconds(4))) + { + logger.Warning( + "CrowdSec flush-on-stop timed out; {Count} item(s) not contributed", + reports.Count + retracts.Count + ); + } + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop failed"); + } + } + + /// + /// Uses a fresh token, not the drain loop's already-cancelled one, which would fail every send + /// immediately. Reports go as one deduped batch; retracts go as individual DELETEs so an admin's + /// unban propagates on a clean shutdown. Leftovers self-heal via + /// . + /// + private async Task FlushRemainingOnStopAsync(List reports, List retracts) + { + using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + if (reports.Count > 0) + { + var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); + try + { + await _client.PostAlertsAsync(alerts, flushCts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop reports failed"); + RecordSendFailure(alerts.Count); + } + } + + HashSet seen = []; + for (var i = 0; i < retracts.Count; i++) + { + if (flushCts.IsCancellationRequested) + { + break; // out of budget; the rest self-heal via ManualBanDuration + } + + var ip = retracts[i].Ip; + if (!seen.Add(ip.ToString())) + { + continue; + } + + try + { + await _client.DeleteDecisionsAsync(_settings.Origin, ip, flushCts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop retract failed for {Address}", ip); + RecordSendFailure(1); + } + } + } + + public void Report(IPAddress address, TimeSpan ttl, string reason) => + Enqueue(new ReportItem(address, ttl, reason, false)); + + public void Retract(IPAddress address) => + Enqueue(new ReportItem(address, TimeSpan.Zero, "retract", true)); + + private void Enqueue(ReportItem item) + { + if (_queue == null || !_queue.Writer.TryWrite(item)) + { + Interlocked.Increment(ref _dropped); + } + } + + // FullMode.Wait (the default) makes TryWrite return false immediately when the channel is full + // instead of blocking the caller — exactly the non-blocking drop-on-overflow behavior the accept + // path requires. DropWrite would silently discard the new item and always report success, which + // would make overflow undetectable. + private static Channel CreateQueue(int capacity) => + Channel.CreateBounded(new BoundedChannelOptions(Math.Max(1, capacity)) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true + }); + + // Must return Task: Start() passes this to Task.Run, which has no Func overload, so a + // ValueTask would bind to Task.Run and yield a Task that completes at the first + // await rather than when the loop exits. + private async Task DrainLoop(CancellationToken token) + { + var reader = _queue.Reader; + + while (!token.IsCancellationRequested) + { + try + { + if (!await reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + return; + } + + // Coalesce a burst before flushing. + await Task.Delay(_settings.FlushInterval, token).ConfigureAwait(false); + + List reports = []; + List retracts = []; + while (reader.TryRead(out var item)) + { + (item.Retract ? retracts : reports).Add(item); + } + + if (reports.Count > 0) + { + var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); + if (!await SendWithBoundedRetryAsync(() => _client.PostAlertsAsync(alerts, token), token) + .ConfigureAwait(false)) + { + RecordSendFailure(alerts.Count); + } + } + + for (var i = 0; i < retracts.Count; i++) + { + var ip = retracts[i].Ip; + if (!await SendWithBoundedRetryAsync(() => _client.DeleteDecisionsAsync(_settings.Origin, ip, token), token) + .ConfigureAwait(false)) + { + RecordSendFailure(1); + } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + // Contribution is auxiliary: log and keep draining. Never crash the shard. + logger.Warning(e, "CrowdSec contribution flush failed; dropped this batch"); + } + } + } + + /// + /// Sends with up to 3 attempts total (1 initial + 2 retries), backing off 1s then 2s between + /// attempts, for transient LAPI failures (network blips, 5xx). Backoff uses + /// so it never blocks the thread; a cancellation during backoff propagates as + /// so the drain loop exits cleanly. Returns false (never + /// throws for a send failure) once attempts are exhausted, so the caller can count the drop and keep + /// draining instead of losing the rest of the batch/queue. + /// + private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) + { + for (var attempt = 0; ; attempt++) + { + try + { + await send().ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + if (attempt >= _retryDelays.Length) + { + logger.Warning(e, "CrowdSec send failed after {Attempts} attempt(s); giving up", attempt + 1); + return false; + } + + var delay = _retryDelays[attempt]; + logger.Warning(e, "CrowdSec send failed (attempt {Attempt}); retrying in {Delay}", attempt + 1, delay); + await Task.Delay(delay, token).ConfigureAwait(false); + } + } + } + + private void RecordSendFailure(int itemCount) + { + var total = Interlocked.Increment(ref _sendFailures); + logger.Warning( + "CrowdSec contribution batch dropped after retries ({Items} item(s)); total dropped batches: {Total}", + itemCount, + total + ); + } + + /// Coalesces items by IP (last write wins) and builds one alert per unique address. + internal static List BuildAlerts(IEnumerable items, CrowdSecSettings settings, DateTime nowUtc) + { + Dictionary byIp = []; + foreach (var item in items) + { + byIp[item.Ip.ToString()] = item; + } + + var timestamp = nowUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var alerts = new List(byIp.Count); + + foreach (var (value, item) in byIp) + { + var ttl = item.Reason == "manual" || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl; + var scenario = $"{settings.Origin}/{item.Reason}"; + + alerts.Add(new CrowdSecAlert + { + Scenario = scenario, + Message = $"ModernUO {item.Reason} ban for {value}", + StartAt = timestamp, + StopAt = timestamp, + Source = new CrowdSecSource { Scope = "Ip", Value = value }, + Decisions = + [ + new CrowdSecDecisionDto + { + Origin = settings.Origin, + Type = "ban", + Scope = "Ip", + Value = value, + Duration = FormatDuration(ttl), + Scenario = scenario + } + ] + }); + } + + return alerts; + } + + /// CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient. + internal static string FormatDuration(TimeSpan ttl) + { + var seconds = (long)ttl.TotalSeconds; + return $"{Math.Max(1, seconds)}s"; + } +} diff --git a/Projects/Server/Network/Firewall/BaseFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/BaseFirewallEntry.cs similarity index 100% rename from Projects/Server/Network/Firewall/BaseFirewallEntry.cs rename to Projects/UOContent/Misc/Firewall/BaseFirewallEntry.cs diff --git a/Projects/Server/Network/Firewall/CidrFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs similarity index 70% rename from Projects/Server/Network/Firewall/CidrFirewallEntry.cs rename to Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs index db74cf620..06527ab71 100644 --- a/Projects/Server/Network/Firewall/CidrFirewallEntry.cs +++ b/Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs @@ -25,8 +25,15 @@ public class CidrFirewallEntry : BaseFirewallEntry public override UInt128 MaxIpAddress { get; } public CidrFirewallEntry(string ipAddressOrCidr) - : this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength) { + // Core owns the CIDR -> normalized range parse. + if (!IPAddressUtility.TryParseCidrRange(ipAddressOrCidr, out var min, out var max)) + { + throw new ArgumentException("Invalid IP address or CIDR.", nameof(ipAddressOrCidr)); + } + + MinIpAddress = min; + MaxIpAddress = max; } public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress) @@ -50,26 +57,4 @@ public class CidrFirewallEntry : BaseFirewallEntry MaxIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, true); } - private static IPAddress ParseIPAddress(ReadOnlySpan ipString, out int prefixLength) - { - var slashIndex = ipString.IndexOf('/'); - var ipAddress = IPAddress.Parse(slashIndex > -1 ? ipString[..slashIndex] : ipString); - var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; - - if (slashIndex == -1) - { - prefixLength = maxPrefixLength; - } - else - { - var prefixPart = ipString[(slashIndex + 1)..]; - - if (!int.TryParse(prefixPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) - { - throw new ArgumentException("Invalid prefix length."); - } - } - - return ipAddress; - } } diff --git a/Projects/UOContent/Misc/Firewall/Firewall.cs b/Projects/UOContent/Misc/Firewall/Firewall.cs new file mode 100644 index 000000000..075b47ec3 --- /dev/null +++ b/Projects/UOContent/Misc/Firewall/Firewall.cs @@ -0,0 +1,413 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: Firewall.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Runtime.CompilerServices; +using Server.Collections; +using Server.Json; +using Server.Logging; + +namespace Server.Network; + +public static class Firewall +{ + // Single-threaded: the accept path, admin gump/command, TTL expiry timer, and boot load all run on + // the main game loop. No locks, caches, or version counters are needed. See the ban-channel design doc. + // _entries is the authoritative store (gump/persistence/TTL/command all work against it); _index is a + // derived, rebuild-on-demand SortedRangeIndex used only for the accept-path IsBlocked lookup, shared + // with the same sorted-range binary-search primitive the blocklist uses (see BlocklistSnapshot). + private static readonly List _entries = []; + + // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. + private static readonly Dictionary _expiring = []; + + private static SortedRangeIndex _index = SortedRangeIndex.Empty; + private static bool _indexDirty; + + private static readonly ILogger logger = LogFactory.GetLogger(typeof(Firewall)); + private const string _path = "Configuration/firewall.json"; + private const string _legacyPath = "firewall.cfg"; + private static bool _dirty; + private static bool _configured; + + public static int FirewallSetCount => _entries.Count; + + public static void ReadFirewallSet(Action> callback) => callback(_entries); + + public static bool IsBlocked(IPAddress address) + { + if (_entries.Count == 0) + { + return false; + } + + EnsureIndex(); + return _index.Contains(address.ToUInt128()); + } + + // Rebuilds the derived lookup index from the authoritative _entries list, but only when entries have + // changed since the last build. Runs on the main game loop, so the pooled build buffer is single-threaded + // (mt: false); only the two final SortedRangeIndex arrays are heap-allocated. + private static void EnsureIndex() + { + if (!_indexDirty) + { + return; + } + + using var ranges = PooledRefList.Range>.Create(_entries.Count, mt: false); + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + ranges.Add(new SortedRangeIndex.Range(entry.MinIpAddress, entry.MaxIpAddress)); + } + + ranges.Sort(SortedRangeIndex.ByMin); + _index = SortedRangeIndex.Build(ranges.AsSpan()); + _indexDirty = false; + } + + public static bool Add(IFirewallEntry firewallEntry) => Add(firewallEntry, TimeSpan.Zero); + + /// + /// Adds an entry. <= means permanent. Returns false + /// if the entry was already present. + /// + // Indexed scan (no closure allocation); firewall lists are small, so O(n) is negligible and this + // stays off the hot path (Add/Remove are admin/boot actions, not the accept path). + private static int IndexOfEntry(IFirewallEntry entry) + { + for (var i = 0; i < _entries.Count; i++) + { + if (_entries[i].CompareTo(entry) == 0) + { + return i; + } + } + + return -1; + } + + public static bool Add(IFirewallEntry firewallEntry, TimeSpan ttl, bool persist = true) + { + if (firewallEntry == null || IndexOfEntry(firewallEntry) >= 0) + { + return false; + } + + _entries.Add(firewallEntry); + + if (ttl > TimeSpan.Zero) + { + _expiring[firewallEntry] = Core.TickCount + (long)ttl.TotalMilliseconds; + } + + _indexDirty = true; + + if (persist) + { + MarkDirty(); + } + + return true; + } + + public static bool Remove(IFirewallEntry entry) + { + if (entry == null) + { + return false; + } + + var index = IndexOfEntry(entry); + if (index < 0) + { + return false; + } + + // Remove the stored instance from _expiring (not the passed reference), so a value-equal + // entry created elsewhere still clears the TTL bookkeeping. + var stored = _entries[index]; + _entries.RemoveAt(index); + _expiring.Remove(stored); + _indexDirty = true; + MarkDirty(); + return true; + } + + /// + /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer (Task 2). + /// + internal static void ExpireEntries(long nowTicks) + { + if (_expiring.Count == 0) + { + return; + } + + List expired = null; + foreach (var (entry, expiresAt) in _expiring) + { + if (expiresAt - nowTicks <= 0) + { + (expired ??= []).Add(entry); + } + } + + if (expired == null) + { + return; + } + + for (var i = 0; i < expired.Count; i++) + { + var entry = expired[i]; + _entries.Remove(entry); + _expiring.Remove(entry); + } + + _indexDirty = true; + MarkDirty(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IFirewallEntry ToFirewallEntry(object entry) => + entry switch + { + IFirewallEntry firewallEntry => firewallEntry, + IPAddress address => new SingleIpFirewallEntry(address), + string s => ToFirewallEntry(s), + _ => null + }; + + public static IFirewallEntry ToFirewallEntry(string entry) + { + if (entry == null) + { + return null; + } + + try + { + var rangeSeparator = entry.IndexOf('-'); + if (rangeSeparator > -1) + { + return new CidrFirewallEntry( + IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), + IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) + ); + } + + if (entry.IndexOf('/') > -1) + { + return new CidrFirewallEntry(entry); + } + + return new SingleIpFirewallEntry(entry); + } + catch + { + return null; + } + } + + public static void Configure() + { + if (_configured) + { + return; + } + _configured = true; + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + LoadFrom(JsonConfig.Deserialize(path)); + } + else + { + var legacyPath = ResolveLegacyCfgPath(); + if (legacyPath != null) + { + MigrateLegacyCfg(legacyPath); + Save(); // materialize firewall.json; the .cfg is no longer read after this + TryMarkLegacyCfgMigrated(legacyPath); + } + } + + // Main-thread maintenance: expire TTLs and flush pending writes. No background thread. + Timer.DelayCall(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), Maintenance); + + // Expose the set to the accept path. Everything else (gump, commands, persistence) keeps using + // the Firewall API directly; only the per-connection question goes through the filter registry. + ConnectionFilters.Register(FirewallConnectionFilter.Instance); + } + + private static void Maintenance() + { + ExpireEntries(Core.TickCount); + + if (_dirty) + { + Save(); + } + } + + private static void MarkDirty() => _dirty = true; + + internal static void LoadFrom(FirewallSettings settings) + { + if (settings?.Entries == null) + { + return; + } + + // Core.Now: this runs on the game loop, via the Configure sweep. + var now = Core.Now; + var records = settings.Entries; + for (var i = 0; i < records.Length; i++) + { + var record = records[i]; + var entry = ToFirewallEntry(record.Value); + if (entry == null) + { + logger.Warning("Ignoring unparseable firewall entry \"{Entry}\"", record.Value); + continue; + } + + var ttl = TimeSpan.Zero; + if (record.Expires is { } expires) + { + ttl = expires - now; + if (ttl <= TimeSpan.Zero) + { + continue; // already expired + } + } + + Add(entry, ttl, persist: false); + } + } + + internal static FirewallSettings ToSettings() + { + // expires is derived below as now + (expiresAtTick - nowTicks), so both operands must come from + // the same instant. Core.Now and Core.TickCount are refreshed together each loop iteration; a + // fresh DateTime.UtcNow here would bake the loop's lag into every persisted expiry. + var now = Core.Now; + var nowTicks = Core.TickCount; + var list = new List(_entries.Count); + + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + DateTime? expires = null; + if (_expiring.TryGetValue(entry, out var expiresAtTick)) + { + expires = now.AddMilliseconds(expiresAtTick - nowTicks); + } + + list.Add(new FirewallEntryRecord { Value = entry.ToString(), Expires = expires }); + } + + return new FirewallSettings { Entries = list.ToArray() }; + } + + public static void Save() + { + _dirty = false; + var path = Path.Join(Core.BaseDirectory, _path); + var tmp = $"{path}.tmp"; + JsonConfig.Serialize(tmp, ToSettings()); + File.Move(tmp, path, overwrite: true); // atomic swap + } + + /// + /// Locates the legacy firewall.cfg to migrate. The modern convention is , + /// checked first; the pre-collapse AdminFirewall used a bare relative path (resolved against the + /// process's current working directory), which may differ from when the + /// shard is launched from elsewhere, so that's checked as a fallback. Returns null if neither exists. + /// + private static string ResolveLegacyCfgPath() + { + var underBaseDirectory = Path.Join(Core.BaseDirectory, _legacyPath); + if (File.Exists(underBaseDirectory)) + { + return underBaseDirectory; + } + + return File.Exists(_legacyPath) ? _legacyPath : null; + } + + private static void MigrateLegacyCfg(string legacyPath) + { + var searchValues = SearchValues.Create("*Xx?"); + + using var reader = new StreamReader(legacyPath); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length == 0) + { + continue; + } + + if (line.AsSpan().ContainsAny(searchValues)) + { + logger.Warning("Legacy firewall entry \"{Entry}\" ignored during migration", line); + continue; + } + + var entry = ToFirewallEntry(line); + if (entry != null) + { + Add(entry, TimeSpan.Zero, persist: false); + } + } + + logger.Information("Migrated {Count} entr(ies) from legacy firewall.cfg to firewall.json", _entries.Count); + } + + /// + /// Renames the migrated .cfg to firewall.cfg.migrated so it isn't re-scanned on the next + /// boot and operators can see it was already migrated. Best-effort: a locked/read-only file must not + /// fail startup, since the migration itself (firewall.json) already succeeded. + /// + private static void TryMarkLegacyCfgMigrated(string legacyPath) + { + try + { + File.Move(legacyPath, $"{legacyPath}.migrated", overwrite: true); + } + catch (Exception e) + { + logger.Warning(e, "Could not rename migrated legacy firewall file \"{Path}\"", legacyPath); + } + } + + internal static void ResetForTesting() + { + _entries.Clear(); + _expiring.Clear(); + _index = SortedRangeIndex.Empty; + _indexDirty = false; + _configured = false; + } +} diff --git a/Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs b/Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs new file mode 100644 index 000000000..570e33f19 --- /dev/null +++ b/Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs @@ -0,0 +1,52 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallConnectionFilter.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 System.Threading; + +namespace Server.Network; + +/// +/// Exposes the admin-curated set to the accept path as an +/// . The firewall keeps its own API (the admin gump and commands mutate +/// it directly); this is only the accept-path adapter, since a static class cannot implement an +/// interface. It is the cheapest gate to consult — an empty set costs one length compare — so +/// registers it first. +/// +internal sealed class FirewallConnectionFilter : IConnectionFilter +{ + public static readonly FirewallConnectionFilter Instance = new(); + + private FirewallConnectionFilter() + { + } + + public string Name => "firewall"; + + // Firewall.Configure() owns loading and registration. + public void Register() + { + } + + // Nothing to hydrate: the set loads at Configure and is maintained by a main-loop timer. + public void Start(CancellationToken token) + { + } + + /// Flushes pending writes on the way down so a TTL expiry or late admin edit is not lost. + public void Stop() => Firewall.Save(); + + public bool ShouldDeny(IPAddress address) => Firewall.IsBlocked(address); +} diff --git a/Projects/UOContent/Misc/Firewall/FirewallSettings.cs b/Projects/UOContent/Misc/Firewall/FirewallSettings.cs new file mode 100644 index 000000000..dd0281c92 --- /dev/null +++ b/Projects/UOContent/Misc/Firewall/FirewallSettings.cs @@ -0,0 +1,42 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallSettings.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.Text.Json.Serialization; + +namespace Server.Network; + +/// +/// Persisted local firewall entries (manual admin bans). Stored at Configuration/firewall.json. +/// Auto-detected rate-limit trips are never persisted — they are contributed to CrowdSec, not stored here. +/// +public record FirewallSettings +{ + [JsonPropertyName("entries")] + public FirewallEntryRecord[] Entries { get; set; } = []; +} + +/// +/// One persisted entry. is a single IP, a min-max range, or CIDR. +/// is UTC wall-clock; null means permanent. +/// +public record FirewallEntryRecord +{ + [JsonPropertyName("value")] + public string Value { get; set; } + + [JsonPropertyName("expires")] + public DateTime? Expires { get; set; } +} diff --git a/Projects/Server/Network/Firewall/IFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/IFirewallEntry.cs similarity index 100% rename from Projects/Server/Network/Firewall/IFirewallEntry.cs rename to Projects/UOContent/Misc/Firewall/IFirewallEntry.cs diff --git a/Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/SingleIpFirewallEntry.cs similarity index 100% rename from Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs rename to Projects/UOContent/Misc/Firewall/SingleIpFirewallEntry.cs diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index 43d4aec15..5eed62249 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -473,6 +473,65 @@ ns.SendMovementRej(int sequence, Mobile m); 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 two that ship are `firewall` +(admin-curated, mutable at runtime, persisted to `Configuration/firewall.json`) and `blocklist` +(file-sourced, millions of entries, demand-pages hits to CrowdSec). A shard that fronts its server with +an upstream proxy or edge scrubbing can drop both and register nothing. + +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 | @@ -492,3 +551,8 @@ ns.SendMovementRej(int sequence, Mobile m); | `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/Misc/Firewall/Firewall.cs` | Admin-curated firewall set | +| `Projects/Server/Utilities/IPAddressUtility.cs` | IPAddress <-> UInt128 normalization, CIDR parsing | +| `Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | diff --git a/tools/Export-IpBlocklist.ps1 b/tools/Export-IpBlocklist.ps1 new file mode 100644 index 000000000..81b9afd06 --- /dev/null +++ b/tools/Export-IpBlocklist.ps1 @@ -0,0 +1,543 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Downloads a small, non-overlapping set of public IP threat feeds and writes them to a single + ModernUO blocklist file -- merged, de-duplicated and bogon-filtered. + +.DESCRIPTION + This is the producer half of ModernUO's in-app blocklist gate. It fetches a deliberately THIN feed + set, merges every source into one global set, drops duplicates and reserved/bogon addresses, then + writes the result to a plain text file that the shard reads via `file` in + Configuration/blocklist.json. Nothing is installed and no credentials are needed -- the output is just + a text file, so this can run on any machine that can reach the shard's Distribution folder. + + It writes the file the shard's `BlocklistFilter` demand-pages against. IPs that actually connect are + promoted to CrowdSec / the OS firewall by the shard; the OS firewall never has to hold millions of + entries, which is exactly the scale it cannot handle on Windows. + + Inclusion principle: any category of IP used in OTHER attacks that could plausibly be turned against a + game server should be blocked -- compromised hosts, botnets, scanners, spam / DDoS-as-a-service bots, + open proxies and Tor relays. That whole surface is already covered by the anchor feed `bitwire-it`, + which is itself a 91-source aggregator (it folds in spamhaus, ipsum, firehol-level2, blocklist-de, + dshield, emergingthreats, binarydefense, cins-army, bruteforceblocker, greensnow, vxvault, ThreatFox, + StopForumSpam/sblam, Tor, open-proxy and C2 lists). So the inclusive posture lives in the base layer, + and every one of those standalone feeds is dropped as pure redundancy. Only the feeds bitwire does NOT + already carry are kept on top of it: + + bitwire-it 2h-refreshed 91-source aggregate (compromised hosts, botnets, scanners, spam + bots, Tor/open-proxy abuse relays, ThreatFox C2) -- the broad base layer. + romainmarcoux ~130k fresh attacker IPs bitwire's snapshot lags on (high-churn feed). + sentinel-turris ~800 unique honeypot probers (Turris greylist) not in bitwire. + firehol-level1 hijacked/reputation NETBLOCKS (spamhaus DROP-style) -- bogon-filtered. + + The only category deliberately held back is commercial VPN exit endpoints, which could block a legit + player -- and those are barely present here anyway (bitwire is ~5% of VPN-tunnel lists). If you ever want + to protect VPN/Tor players, pass -ExcludeAnonymizers to subtract Tor/open-proxy/VPN IPs from the output. + + OUTPUT FORMAT (must stay in sync with UOContent/Misc/Blocklist/BlocklistFile.cs): + Line 1 is a header comment carrying the version markers, e.g. + # modernuo-blocklist generated=2026-07-25T18:03:11Z count=3914022 ipv4=3901188 cidr=12834 + The shard polls `reloadInterval` and reloads when the file mtime AND `generated=` change, + so the header is REQUIRED -- without it the shard loads once and never picks up a new file. + Every following line is one entry: a bare IPv4/IPv6 address or a CIDR (`1.2.3.0/24`). Blank lines + and lines starting with `#` or `;` are ignored. Order does not matter; the shard sorts and + coalesces on load. The feeds used here are IPv4-only, but the shard parses IPv6 lines too. + + The file is written to a `.tmp` sibling and swapped into place atomically, so the shard never reads a + half-written list -- it either sees the previous version or the new one, whole. + + Performance: bitwire alone is ~4M lines. Parsing/validating/bogon-filtering that in interpreted + PowerShell is the slow part (minutes), so the hot loop is compiled once via Add-Type (C#) -- it runs in + ~1s. Downloads stream with a live Write-Progress bar; every phase prints its own elapsed time so you can + see exactly where the wall-clock goes. + + Requires PowerShell 7 (pwsh), which runs on Windows, Linux and macOS -- Windows PowerShell 5.1 is + not supported and the script refuses to run there. Schedule it with Task Scheduler, cron, or a + systemd timer. + + Every run rewrites the whole file, so an IP that drops off the feeds stops being blocked on the next + run -- there is no TTL to tune. Calling it is idempotent: if the list on disk is younger than + -MinInterval the script exits without downloading anything, so an over-eager trigger costs nothing + upstream. -Force overrides that. + +.PARAMETER DistributionPath + Path to the shard's Distribution folder. The blocklist is written to the Configuration/ip-blocklist.txt + beneath it, which is the default `file` in blocklist.json. Not needed when the script is run from its + place in the repo (tools/), or when -OutFile is given. + +.PARAMETER OutFile + Explicit output path, overriding -DistributionPath. Use this if you relocated the blocklist and + changed `file` in blocklist.json to match. + +.PARAMETER MinInterval + Refuse to re-run while the existing blocklist is younger than this (default 2h), so a misbehaving + scheduler, a login script or a stuck retry loop cannot hammer the upstream feeds. The age comes from + the `generated=` header of the file already on disk (falling back to its mtime), so it survives across + machines and reboots -- there is no separate state file. Nothing is downloaded when the check trips. + Accepts `90s`, `45m`, `2h`, `2.5h`, `1d`, or a bare number of hours. Use `0` to disable the check. + Match this to how often you actually want fresh data: the anchor feed only refreshes every 2h, so + running more often than that costs bandwidth and gains nothing. + +.PARAMETER Force + Run regardless of how recently the blocklist was generated (bypasses -MinInterval). + +.PARAMETER Feeds + Which feeds to include (by Name). Default: all of them. + +.PARAMETER ExcludeAnonymizers + Also download Tor-exit / open-proxy / VPN-tunnel lists and SUBTRACT those IPs from the output. Off by + default -- for a game server, Tor/open-proxy relays are attack infrastructure you want to block. Turn + this on only if you need to keep VPN/Tor players reachable. + +.PARAMETER DryRun + Download + parse + merge + count only. Writes nothing. + +.EXAMPLE + .\Export-IpBlocklist.ps1 -DryRun + +.EXAMPLE + # Safe to call as often as you like -- it no-ops unless the list is older than 2h. + .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' + +.EXAMPLE + .\Export-IpBlocklist.ps1 -OutFile 'D:\shared\ip-blocklist.txt' -ExcludeAnonymizers + +.EXAMPLE + # Regenerate right now, ignoring the cooldown. + .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' -Force + +.EXAMPLE + # Linux/macOS, e.g. from cron: + pwsh -File /opt/modernuo/Export-IpBlocklist.ps1 -DistributionPath /opt/modernuo/Distribution + +.NOTES + Feeds are aggressive-but-low-FP for a game server (attacker / botnet / compromised / abuse-relay SOURCE + IPs). Reserved/bogon space (0/8, 10/8, 127/8, RFC1918, multicast, etc.) is always filtered out -- this + matters because firehol-level1 ships bogon netblocks that would otherwise block private/reserved ranges. +#> +[CmdletBinding()] +param( + [string] $DistributionPath, + [string] $OutFile, + [string] $MinInterval = '2h', + [string[]] $Feeds, + [switch] $ExcludeAnonymizers, + [switch] $Force, + [switch] $DryRun +) + +$ErrorActionPreference = 'Stop' +$UA = 'ModernUO-Blocklist-Export' +$totalSw = [System.Diagnostics.Stopwatch]::StartNew() + +# Default location under the Distribution folder. Keep in sync with BlocklistSettings.File. +# Kept as separate segments (never a literal 'a\b') so Join-Path picks the right separator per OS. +$DefaultPathSegments = @('Configuration', 'ip-blocklist.txt') + +# --------------------------------------------------------------------------------------------------------- +# Resolve the output path. Explicit -OutFile wins; then -DistributionPath; then the in-repo layout +# (tools\ sits next to Distribution\) so a checkout works with no arguments at all. The script is meant to +# be copied onto the shard host, and there it needs -DistributionPath (or -OutFile). +# --------------------------------------------------------------------------------------------------------- +if (-not $OutFile) { + if (-not $DistributionPath -and $PSScriptRoot) { + $inRepo = Join-Path (Split-Path -Parent $PSScriptRoot) 'Distribution' + if (Test-Path -LiteralPath $inRepo -PathType Container) { $DistributionPath = $inRepo } + } + if (-not $DistributionPath) { + throw "Could not locate the shard's Distribution folder. Pass -DistributionPath 'C:\path\to\Distribution' (or -OutFile)." + } + if (-not (Test-Path -LiteralPath $DistributionPath -PathType Container)) { + throw "DistributionPath '$DistributionPath' does not exist." + } + $OutFile = Join-Path $DistributionPath @DefaultPathSegments +} + +# --------------------------------------------------------------------------------------------------------- +# Cooldown gate. Runs BEFORE anything is downloaded: the whole point is that a misconfigured scheduler or a +# retry loop cannot spam the upstream feeds. State lives in the output file itself (`generated=` header, +# mtime as fallback), so it is correct across reboots, machines and hand-runs with no sidecar state file. +# --------------------------------------------------------------------------------------------------------- +function ConvertTo-Duration { + param([string]$Text) + if ([string]::IsNullOrWhiteSpace($Text)) { return [TimeSpan]::Zero } + $t = $Text.Trim().ToLowerInvariant() + $unit = $t[$t.Length - 1] + $numText = if ($unit -match '[0-9.]') { $t } else { $t.Substring(0, $t.Length - 1) } + $n = 0.0 + # InvariantCulture is not optional here: under a comma-decimal locale (de-DE, fr-FR, ...) the + # current-culture parse reads '2.5' as 25 -- it treats '.' as a group separator and SUCCEEDS, so + # `-MinInterval 2.5h` would silently become a 25 hour cooldown instead of failing loudly. + if (-not [double]::TryParse($numText, [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture, [ref]$n)) { + throw "Could not parse duration '$Text' (try 90s, 45m, 2h, 2.5h, 1d)." + } + switch ($unit) { + 's' { return [TimeSpan]::FromSeconds($n) } + 'm' { return [TimeSpan]::FromMinutes($n) } + 'h' { return [TimeSpan]::FromHours($n) } + 'd' { return [TimeSpan]::FromDays($n) } + default { return [TimeSpan]::FromHours($n) } # bare number == hours + } +} + +# Age of the list already on disk, or $null when there is nothing usable to age. +function Get-BlocklistAge { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + # Prefer the header we wrote: it describes the data, not the file, so copying/restoring the file + # cannot make a stale list look fresh (or a fresh one look stale). + try { + $first = Get-Content -LiteralPath $Path -TotalCount 1 -ErrorAction Stop + if ($first -and $first.StartsWith('#')) { + foreach ($tok in $first.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)) { + if ($tok.StartsWith('generated=', [StringComparison]::Ordinal)) { + $stamp = [DateTime]::MinValue + $styles = [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal + if ([DateTime]::TryParse($tok.Substring(10), [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$stamp)) { + return @{ Age = ([DateTime]::UtcNow - $stamp); Stamp = $tok.Substring(10); Source = 'header' } + } + } + } + } + } + catch { } + + # Hand-maintained or truncated file: fall back to the filesystem timestamp. + try { + $w = (Get-Item -LiteralPath $Path -ErrorAction Stop).LastWriteTimeUtc + return @{ Age = ([DateTime]::UtcNow - $w) + Stamp = $w.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) + Source = 'mtime' } + } + catch { return $null } +} + +$minAge = ConvertTo-Duration $MinInterval +if (-not $Force -and $minAge -gt [TimeSpan]::Zero) { + $existing = Get-BlocklistAge -Path $OutFile + if ($existing) { + # A negative age means the stamp is in the future (clock skew, or a file from another host). Treat it + # as fresh: refusing to run is the recoverable failure, hammering the feeds on every tick is not. + if ($existing.Age -lt $minAge) { + $agoText = if ($existing.Age -lt [TimeSpan]::Zero) { 'in the future -- check the clock' } else { ("{0:N1}h ago" -f $existing.Age.TotalHours) } + Write-Host ("Blocklist at {0} was generated {1} ({2}={3}); newer than -MinInterval {4}." -f ` + $OutFile, $agoText, $existing.Source, $existing.Stamp, $MinInterval) + Write-Host "Nothing downloaded. Pass -Force to regenerate now, or lower -MinInterval." + return + } + } +} + +# --------------------------------------------------------------------------------------------------------- +# Compiled hot loop. Interpreted PowerShell chokes on bitwire's ~4M lines; this parses + validates + bogon- +# filters + de-dupes in one compiled pass, and writes the final file directly (no 4M-element PS pipelines). +# Deliberately plain C#: no LINQ, no generics beyond HashSet, nothing that would slow the hot loop. +# --------------------------------------------------------------------------------------------------------- +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +public static class BlocklistExporter +{ + static bool TryParseIPv4(string s, int start, int len, out uint val) + { + val = 0; + uint acc = 0; int octet = 0, dots = 0, digits = 0, end = start + len; + for (int i = start; i < end; i++) + { + char c = s[i]; + if (c == '.') + { + if (digits == 0 || octet > 255) return false; + acc = (acc << 8) | (uint)octet; dots++; octet = 0; digits = 0; + } + else if (c >= '0' && c <= '9') + { + octet = octet * 10 + (c - '0'); if (++digits > 3) return false; + } + else return false; + } + if (dots != 3 || digits == 0 || octet > 255) return false; + val = (acc << 8) | (uint)octet; + return true; + } + + static bool IsBogon(uint start, uint end, uint[] bs, uint[] be) + { + for (int i = 0; i < bs.Length; i++) + if (start <= be[i] && end >= bs[i]) return true; + return false; + } + + // Parse one feed's text; add bare IPs to `singles`, CIDRs to `cidrs`. Returns count newly added. + public static int AddContent(string content, HashSet singles, HashSet cidrs, uint[] bs, uint[] be) + { + int added = 0, n = content.Length, i = 0; + while (i < n) + { + int eol = content.IndexOf('\n', i); + int lineEnd = (eol < 0) ? n : eol; + int a = i, b = lineEnd; + while (a < b && (content[a] == ' ' || content[a] == '\t' || content[a] == '\r')) a++; + while (b > a && (content[b - 1] == ' ' || content[b - 1] == '\t' || content[b - 1] == '\r')) b--; + i = (eol < 0) ? n : eol + 1; + if (a >= b) continue; + char first = content[a]; + if (first == '#' || first == ';') continue; + + // Feeds vary: some are bare IPs, some are CSV/whitespace records with the IP first. + int t = a; + while (t < b) + { + char c = content[t]; + if (c == ' ' || c == '\t' || c == ',' || c == ';') break; + t++; + } + int slash = -1; + for (int k = a; k < t; k++) { if (content[k] == '/') { slash = k; break; } } + + if (slash >= 0) + { + uint ip; + if (!TryParseIPv4(content, a, slash - a, out ip)) continue; + int bits = 0, bd = 0; + for (int k = slash + 1; k < t; k++) + { + char c = content[k]; + if (c < '0' || c > '9') { bd = -1; break; } + bits = bits * 10 + (c - '0'); bd++; + } + if (bd <= 0 || bits > 32) continue; + ulong size = (bits == 0) ? 0xFFFFFFFFUL : ((1UL << (32 - bits)) - 1UL); + ulong endAddr = (ulong)ip + size; if (endAddr > 0xFFFFFFFFUL) endAddr = 0xFFFFFFFFUL; + if (IsBogon(ip, (uint)endAddr, bs, be)) continue; + if (cidrs.Add(content.Substring(a, t - a))) added++; + } + else + { + uint ip; + if (!TryParseIPv4(content, a, t - a, out ip)) continue; + if (IsBogon(ip, ip, bs, be)) continue; + if (singles.Add(ip)) added++; + } + } + return added; + } + + static int WriteOctet(char[] buf, int pos, uint v) + { + if (v >= 100) { buf[pos++] = (char)('0' + v / 100); buf[pos++] = (char)('0' + (v / 10) % 10); } + else if (v >= 10) { buf[pos++] = (char)('0' + v / 10); } + buf[pos++] = (char)('0' + v % 10); + return pos; + } + + // Writes the whole blocklist in one streamed pass so we never materialize millions of strings. + // Header first (the shard's reload detector requires it), then singles, then CIDRs. LF line endings. + public static void Write(string path, string header, HashSet singles, HashSet cidrs) + { + using (var w = new StreamWriter(path, false, new UTF8Encoding(false), 1 << 20)) + { + w.Write(header); w.Write('\n'); + + char[] buf = new char[20]; + foreach (uint v in singles) + { + int p = 0; + p = WriteOctet(buf, p, (v >> 24) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (v >> 16) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (v >> 8) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, v & 255); buf[p++] = '\n'; + w.Write(buf, 0, p); + } + + foreach (string c in cidrs) { w.Write(c); w.Write('\n'); } + } + } +} +'@ + +# --------------------------------------------------------------------------------------------------------- +# Feed set -- thin, non-overlapping. See .DESCRIPTION for why each is kept and what was dropped as redundant. +# romainmarcoux's "full" set is sharded; only aa..ad carry data today (ae.. are empty placeholders). +# A 404/empty shard is skipped, so extend this list if upstream grows the shard count. +# --------------------------------------------------------------------------------------------------------- +$rmBase = 'https://raw.githubusercontent.com/romainmarcoux/malicious-ip/main/full-300k-' +$rmShards = @('aa','ab','ac','ad') | ForEach-Object { $rmBase + $_ + '.txt' } + +$AllFeeds = @( + [pscustomobject]@{ Name = 'bitwire-it'; Urls = @('https://raw.githubusercontent.com/bitwire-it/ipblocklist/main/inbound.txt') } + [pscustomobject]@{ Name = 'romainmarcoux'; Urls = $rmShards } + [pscustomobject]@{ Name = 'sentinel-turris';Urls = @('https://view.sentinel.turris.cz/greylist-data/greylist-latest.csv') } + [pscustomobject]@{ Name = 'firehol-level1'; Urls = @('https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset') } +) + +# Anonymizer / relay lists subtracted only when -ExcludeAnonymizers is set (Tor exits, open proxies, VPN tunnels). +$AnonFeeds = @( + 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/tor_exits.ipset' + 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/sslproxies_7d.ipset' + 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/socks_proxy_7d.ipset' + 'https://raw.githubusercontent.com/ShadowWhisperer/IPs/master/Lists/Tunnels' +) + +if ($Feeds) { + $AllFeeds = $AllFeeds | Where-Object { $Feeds -contains $_.Name } + if (-not $AllFeeds) { throw "No feeds matched -Feeds." } +} + +# --------------------------------------------------------------------------------------------------------- +# Reserved / bogon ranges -- never valid attacker SOURCE IPs; always filtered. Built once as uint32 arrays. +# --------------------------------------------------------------------------------------------------------- +function ConvertTo-IPv4UInt { + param([string]$s) + $a = $s.Split('.') + if ($a.Length -ne 4) { return $null } + $v = [uint32]0 + foreach ($o in $a) { + $n = 0 + if (-not [int]::TryParse($o, [ref]$n) -or $n -lt 0 -or $n -gt 255) { return $null } + $v = ($v -shl 8) -bor [uint32]$n + } + return $v +} + +$bogonCidrs = '0.0.0.0/8','10.0.0.0/8','100.64.0.0/10','127.0.0.0/8','169.254.0.0/16','172.16.0.0/12', + '192.0.0.0/24','192.0.2.0/24','192.168.0.0/16','198.18.0.0/15','198.51.100.0/24', + '203.0.113.0/24','224.0.0.0/3' # 224/3 covers multicast + reserved + 255.255.255.255 +$bogStart = [System.Collections.Generic.List[uint32]]::new() +$bogEnd = [System.Collections.Generic.List[uint32]]::new() +foreach ($c in $bogonCidrs) { + $p = $c.Split('/'); $base = ConvertTo-IPv4UInt $p[0]; $bits = [int]$p[1] + $size = [uint32]([Math]::Pow(2, 32 - $bits)) + $bogStart.Add($base); $bogEnd.Add([uint32]($base + $size - 1)) +} +$bogStart = $bogStart.ToArray(); $bogEnd = $bogEnd.ToArray() + +# --------------------------------------------------------------------------------------------------------- +# Streaming download with a live progress bar (Write-Progress) so large feeds show real byte progress. +# --------------------------------------------------------------------------------------------------------- +function Get-Url { + param([string]$Url, [string]$Label) + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.UserAgent = $UA; $req.Timeout = 120000; $req.ReadWriteTimeout = 120000 + $resp = $req.GetResponse() + try { + $total = $resp.ContentLength + $stream = $resp.GetResponseStream() + $ms = New-Object System.IO.MemoryStream + $buf = New-Object byte[] (1MB) + $read = 0; $lastReport = 0 + while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) { + $ms.Write($buf, 0, $n); $read += $n + if ($read - $lastReport -ge 2MB) { + $lastReport = $read + if ($total -gt 0) { + Write-Progress -Activity ("Downloading {0}" -f $Label) -PercentComplete ([int](100 * $read / $total)) ` + -Status ("{0:N1} / {1:N1} MB" -f ($read / 1MB), ($total / 1MB)) + } else { + Write-Progress -Activity ("Downloading {0}" -f $Label) -Status ("{0:N1} MB" -f ($read / 1MB)) + } + } + } + Write-Progress -Activity ("Downloading {0}" -f $Label) -Completed + return [System.Text.Encoding]::UTF8.GetString($ms.ToArray()) + } + finally { $resp.Close() } +} + +# --------------------------------------------------------------------------------------------------------- +# Collect every kept feed into ONE global set, timing each phase. +# --------------------------------------------------------------------------------------------------------- +$singles = [System.Collections.Generic.HashSet[uint32]]::new() +$cidrs = [System.Collections.Generic.HashSet[string]]::new() +$feedCount = 0 + +foreach ($feed in $AllFeeds) { + $before = $singles.Count + $cidrs.Count + $ok = $false + foreach ($url in $feed.Urls) { + try { + $dlSw = [System.Diagnostics.Stopwatch]::StartNew() + $content = Get-Url -Url $url -Label $feed.Name + $dlSw.Stop() + $mb = [Math]::Round($content.Length / 1MB, 1) + + $pSw = [System.Diagnostics.Stopwatch]::StartNew() + [void][BlocklistExporter]::AddContent($content, $singles, $cidrs, $bogStart, $bogEnd) + $pSw.Stop() + Write-Host (" [dl {0,6:N1}s / parse {1,5:N1}s] {2}" -f $dlSw.Elapsed.TotalSeconds, $pSw.Elapsed.TotalSeconds, ("{0} ({1} MB)" -f $feed.Name, $mb)) + $ok = $true + } + catch { Write-Warning ("{0}: {1} -- skipping shard ({2})" -f $feed.Name, $url, $_.Exception.Message) } + } + if ($ok) { + $feedCount++ + $delta = ($singles.Count + $cidrs.Count) - $before + Write-Host ("{0,-16} +{1,8} new (running total {2} ip / {3} cidr)`n" -f $feed.Name, $delta, $singles.Count, $cidrs.Count) + } + else { Write-Warning ("{0}: all sources failed -- skipping" -f $feed.Name) } +} + +# --------------------------------------------------------------------------------------------------------- +# Optional: subtract Tor / open-proxy / VPN IPs. +# --------------------------------------------------------------------------------------------------------- +if ($ExcludeAnonymizers) { + $anon = [System.Collections.Generic.HashSet[uint32]]::new() + $anonCidr = [System.Collections.Generic.HashSet[string]]::new() + foreach ($url in $AnonFeeds) { + try { [void][BlocklistExporter]::AddContent((Get-Url -Url $url -Label 'anonymizers'), $anon, $anonCidr, $bogStart, $bogEnd) } + catch { Write-Warning ("anonymizer list {0}: {1}" -f $url, $_.Exception.Message) } + } + $removed = 0 + foreach ($ip in @($anon)) { if ($singles.Remove($ip)) { $removed++ } } + Write-Host ("ExcludeAnonymizers: removed {0} Tor/proxy/VPN single IPs" -f $removed) +} + +$total = $singles.Count + $cidrs.Count +Write-Host ("Merged {0} feed(s): {1} unique single IPs + {2} unique CIDRs (bogon-filtered) in {3:N1}s." -f ` + $feedCount, $singles.Count, $cidrs.Count, $totalSw.Elapsed.TotalSeconds) + +if ($DryRun) { + Write-Host ("DRY RUN: nothing written (would have written {0} entries to {1})." -f $total, $OutFile) + return +} + +# A partial feed outage must not silently shrink the shard's blocklist to nothing; keep the last good file. +if ($total -eq 0) { throw "No entries parsed -- refusing to overwrite '$OutFile' with an empty list." } + +# --------------------------------------------------------------------------------------------------------- +# Write to a .tmp sibling and swap it into place, so the shard (which reads the whole file on a change) +# never observes a half-written list. One rename does it whether or not a list is already there. +# --------------------------------------------------------------------------------------------------------- +$outDir = Split-Path -Parent $OutFile +if ($outDir -and -not (Test-Path -LiteralPath $outDir -PathType Container)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null +} + +# InvariantCulture: ':' is the culture-defined time separator in a custom format string, and the +# header is a machine-read marker the shard compares verbatim. +$generated = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) +$header = "# modernuo-blocklist generated=$generated count=$total ipv4=$($singles.Count) cidr=$($cidrs.Count) feeds=$feedCount" + +$tmp = $OutFile + '.tmp' +$wSw = [System.Diagnostics.Stopwatch]::StartNew() +try { + [BlocklistExporter]::Write($tmp, $header, $singles, $cidrs) + # One atomic rename over the destination on every platform: MoveFileEx REPLACE_EXISTING on + # Windows, rename(2) on Linux and macOS. + [IO.File]::Move($tmp, $OutFile, $true) +} +finally { + # Never leave a partial .tmp next to a live blocklist for the next run to trip over. + if (Test-Path -LiteralPath $tmp -PathType Leaf) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } +} +$wSw.Stop() + +$sizeMb = [Math]::Round((Get-Item -LiteralPath $OutFile).Length / 1MB, 1) +Write-Host ("`nWrote {0} entries ({1} MB) to {2} in {3:N1}s (total {4:N1}s). generated={5}" -f ` + $total, $sizeMb, $OutFile, $wSw.Elapsed.TotalSeconds, $totalSw.Elapsed.TotalSeconds, $generated) +Write-Host "The shard picks this up on its next reloadInterval poll; no restart needed." From 9c11ccdb80cc68e2b35af60fbaefdf00e4f95dcb Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:07:20 -0700 Subject: [PATCH 19/64] fix(pathfinding): stop opening every .swb twice at boot (#2548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every map's `.swb` step cache was opened, indexed and logged **twice** on boot. `MovementPath.Configure()` explicitly called `PathCacheCommands.Configure()` and `CacheEvictionTimer.Configure()`. Both are types exposing a public static parameterless `Configure()`, which `AssemblyHandler.Invoke("Configure")` already discovers and calls once each (`AssemblyHandler.cs:157`). So `PathCacheCommands.Configure()` ran twice, and `AutoLoadAtStartup()` with it. `PathCacheCommands.Configure()` called `PathfindRecorder.Configure()` the same way. `TryOpenLazyReader` disposes the prior reader before replacing it, so there was no handle leak — but the header and full chunk index of each `.swb` were read twice (~48 MB of files across six facets). The expensive `.mul` hashing was already memoized, so it was not doubled. ## Fix Consolidate the cache lifecycle into `Initialize`: - `Configure()` keeps only settings and command registration. - `Initialize()` opens the readers once, then prebakes only maps that still lack one. - The post-bake reopen is per-map instead of a blanket `AutoLoadAtStartup()` — on a partial bake (some valid `.swb`, one stale) that would close and reopen the readers already open, a second double-open on a different path. `Initialize` is the correct phase. `Configure` runs before `TileMatrixLoader.LoadTileMatrix()` and `World.Load()` (`Main.cs:458/460/463/465`), so opening a `.swb` there forced the lazy `Map.Tiles` property — the fingerprint hashes the map files — and built every `TileMatrix` ahead of the loader that owns it, possibly before `TileMatrix.Configure()` settled `Pre6000ClientSupport`. Both sit at the default call priority and the phase sort is unstable. Moving pathfinding out leaves nothing in `Configure` that touches `Map.Tiles`, closing that hazard; the other 22 `.Tiles` users in UOContent are all runtime paths. Multis stay out of the bake by design — houses and boats are player data that moves, handled by the multi-aware path at query time. ## Logging The per-map `StepCache: opened ... chunks indexed` line drops to `Debug`. Opening is the expected case; `BakeMap` already logs a rebuild at `Information`, and `Initialize` still emits `PathBake: pre-bake complete (N map(s) written)`. ## Verification - `dotnet build Projects/UOContent` — 0 errors, 0 warnings. - `dotnet test --filter FullyQualifiedName~Pathfinding` — **123 passed, 0 failed**. Boot logs should now show one `opened` line per map at `Debug`, none at `Information`. --- .../Engines/Pathing/Cache/StepCache.cs | 3 +- .../UOContent/Engines/Pathing/MovementPath.cs | 2 -- .../Engines/Pathing/PathCacheCommands.cs | 28 +++++++++++-------- dev-docs/pathfinding.md | 16 +++++++---- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index a1b368cb0..adf178927 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -285,7 +285,8 @@ public sealed class StepCache } _lazyReaders[mapId] = reader; - logger.Information( + // Debug: opening is the expected case. A rebuild is the interesting one, and BakeMap logs it. + logger.Debug( "StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}", path, reader.IndexedChunkCount, mapId ); diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index b5307ad3d..32aa8ba11 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -60,8 +60,6 @@ public sealed class MovementPath public static void Configure() { CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); - CacheEvictionTimer.Configure(); - PathCacheCommands.Configure(); } [Usage("Path")] diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index f14886564..7f8fee270 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -39,15 +39,12 @@ public static class PathCacheCommands 8192 ); - PathfindRecorder.Configure(); - CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake); CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave); CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad); CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord); - AutoLoadAtStartup(); } /// @@ -80,18 +77,23 @@ public static class PathCacheCommands } /// - /// Bakes any map whose .swb is missing or stale, when is - /// set. Runs in the Initialize phase, once the tile matrix and world are loaded. An up-to-date - /// cache makes it a no-op, so the cost lands only on a first boot or after a client or map - /// update moves the fingerprint. + /// Opens the existing .swb files, then — when is set — + /// bakes any that are missing or stale. An up-to-date cache makes the bake a no-op, so the cost + /// lands only on a first boot or after a client or map update moves the fingerprint. /// - /// A map is judged up-to-date by whether it has an open reader. - /// already ran in the earlier Configure phase and only opens a reader for a .swb whose - /// fingerprint validates, so an open reader is proof of a good bake — no need to fingerprint - /// the map a second time here. + /// Both halves run here rather than in Configure: the fingerprint hashes the map files, so + /// opening a .swb forces the lazy property. In Configure that would + /// build every TileMatrix ahead of TileMatrixLoader, possibly before + /// TileMatrix.Configure() settles Pre6000ClientSupport — both sit at the default + /// call priority and the phase sort is unstable. + /// + /// A reader only opens once its fingerprint validates, so an open reader is proof of a good + /// bake and the map is skipped without fingerprinting it again. /// public static void Initialize() { + AutoLoadAtStartup(); + if (!ServerConfiguration.GetSetting(PrebakeSetting, false)) { return; @@ -119,13 +121,15 @@ public static class PathCacheCommands ); StepCache.Instance.BakeMap(map.MapID, path); StepCache.Instance.ClearResidentChunks(); + + // Just this map: a blanket AutoLoadAtStartup() would reopen every reader already open. + StepCache.Instance.TryOpenLazyReader(path, map.MapID); baked++; } if (baked > 0) { logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked); - AutoLoadAtStartup(); // reopen what we just wrote } } diff --git a/dev-docs/pathfinding.md b/dev-docs/pathfinding.md index dfe3d8e09..861a41d75 100644 --- a/dev-docs/pathfinding.md +++ b/dev-docs/pathfinding.md @@ -139,13 +139,17 @@ several-minutes cost. Wiring: after assemblies load (so content can register prompts) but **before Serilog starts**, so the console prompt is not interleaved with the async console sink. Any class can participate by defining `public static void ConfigurePrompts()` and self-gating on first-boot state. -- The bake runs in the later `Invoke("Initialize")` phase (after the tile matrix + world load, - which the bake walks). +- Both the reader open and the bake run in `Invoke("Initialize")`, after the tile matrix and world + load. Neither belongs in `Configure`, which runs *before* both: the fingerprint hashes the map + files, so opening a `.swb` there would force the lazy `Map.Tiles` property and build every + `TileMatrix` ahead of `TileMatrixLoader` — possibly before `TileMatrix.Configure()` settles + `Pre6000ClientSupport`, since both sit at the default call priority and the phase sort is + unstable. `PathCacheCommands.Configure` is limited to settings and command registration. - Staleness is decided by the `.swb` fingerprint, which `StepCacheFile.OpenForLazy` validates at open time (hash of `tiledata.mul` + the per-map `.mul`/`.uop` files — never the in-memory - `TileData` tables, which the server patches at runtime). `Configure` opens a reader for every - up-to-date file; the bake in `Initialize` then skips any map where `StepCache.HasLazyReader` is - already true, so the fingerprint is computed once per boot, not twice. + `TileData` tables, which the server patches at runtime). `Initialize` opens a reader for every + up-to-date file, then skips any map where `StepCache.HasLazyReader` is already true, so the + fingerprint is computed once per boot. Each newly baked map reopens only itself. ## Configuration levers @@ -154,7 +158,7 @@ several-minutes cost. Wiring: | `pathfinding.enable` | `PathFollower.Configure` | `true` | Master switch for `PathFollower` pathfinding. Off → greedy/auto-turn only, no A* at all. | | `bitmap_pathfinding_cache` feature flag (`ContentFeatureFlags.BitmapPathfindingCache`, `Server.Systems.FeatureFlags`) | `FeatureFlagManager` | `true` | Off → `BitmapAStar` routes straight to the slow path with **no cache probe and no warming memory**. ≈ old FastAStar at ~1×. | | `pathfinding.maxResidentChunks` | `PathCacheCommands.Configure` | 8192 (~40 MB) | LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 512–1024 ≈ 2.5–5 MB) on small shards. | -| `pathfinding.maxSearchNodes` | `PathCacheCommands.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | +| `pathfinding.maxSearchNodes` | `BitmapAStarAlgorithm.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | | `pathfinding.prebakeMaps` | `PathCacheCommands` (first-boot prompt + `Initialize`) | `false` | When set, bakes any missing/stale `.swb` for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt. | | `PathFollower` `RepathDelay` | `PathFollower.cs` (const) | 2 s | Throttle: a moving goal re-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). | From 1a9cec1dbb2699e6aac099cea7030d038e281958 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:32:06 -0700 Subject: [PATCH 20/64] fix(advancedsearch): clear pause and sample exit before signaling the drain (#2549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `AdvancedSearchThreadWorker.Execute` signals `_stopEvent` **before** clearing `_pause` and **before** reading the exit condition. `Sleep()` unblocks the instant that signal fires, so the owning thread can begin the next cycle while the worker is still finishing the previous one — and the worker's two trailing operations then land on the new cycle's state. `SerializationThreadWorker` already orders the same handshake correctly and documents why (`Projects/Server/Serialization/SerializationThreadWorker.cs`): ```csharp // The owning thread may start another pause cycle the moment _stopEvent is set // (Exit does exactly that). Clear _pause and sample the exit condition before // signaling, or the new cycle's pause request is clobbered / its Sleep orphaned. var exiting = Core.Closing || worker._exit; Volatile.Write(ref worker._pause, false); worker._stopEvent.Set(); ``` This applies the same ordering to the search worker. Three lines; no behavior change on the happy path. ## The two failures **Reuse hang.** The next cycle's `Wake`/`Push`/`Sleep` writes `_pause = true`, then the worker's stale `_pause = false` lands on top of it. The inner loop never observes `pauseRequested`, its queue is already empty, and it spins on `Thread.Yield()` forever — so the owning thread's next `Sleep()` waits on a `_stopEvent` that is never set again. A single search wakes each worker exactly once, so this only surfaces once `_threadWorkers` is reused by a later search. **Orphaned `Exit()`.** `Exit()` sets `_exit`, `Wake()`s, then `Sleep()`s — the moment the drain's `Sleep()` returns. Reading `_exit` *after* the signal, the worker can observe that fresh `_exit`, return without ever consuming the `Wake`, and leave `Exit()`'s `Sleep()` waiting on a signal nobody will send. The `_thread.IsAlive` guard doesn't close this: the thread passes the check and returns immediately after. ## Verification Verified with two throwaway timing tests — 25k reuse cycles and 2k drain-then-`Exit` cycles, each under a bounded wait: | ordering | result | |---|---| | previous | `Failed: 2, Passed: 3` — both reproduce, cleanly at the 20s bound | | this PR | 3 consecutive runs, 5/5, ~0.6s | **Those tests are deliberately not included.** Their reproduction threshold is a property of one machine's scheduler — at 2k and 200 cycles the buggy build passed — so as permanent tests they'd cost ~560ms and 2000 thread creations on every suite run for a guarantee that may not hold on a CI runner. The ordering is protected the same way `SerializationThreadWorker`'s is: by the comment at the call site. `UOContent.Tests`: **597/597**. --- .../Advanced Search/AdvancedSearchThreadWorker.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index a4a26c244..5206477c9 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -117,10 +117,15 @@ public class AdvancedSearchThreadWorker } } - worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished + // The owning thread may start another cycle the moment _stopEvent is set (Exit does exactly + // that). Clear _pause and sample the exit condition before signaling, or the new cycle's + // pause request is clobbered / its Sleep orphaned. Matches SerializationThreadWorker. + var exiting = Core.Closing || Volatile.Read(ref worker._exit); Volatile.Write(ref worker._pause, false); - if (Core.Closing || Volatile.Read(ref worker._exit)) + worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished + + if (exiting) { return; } From c909ed1f2f76c1b0e77305dd98ad078dd2298542 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:47:49 -0700 Subject: [PATCH 21/64] fix: Streamlines insurance. Insurance only executes when enabled. (#2550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Moves inventory insurance out of `Mobile`/`PlayerMobile` into its own system at `Projects/UOContent/Engines/Insurance/`, wires it into the feature flag system, and makes disabling it actually disable it everywhere. ### Changes **New `Server.Engines.Insurance.Insurance` system** * Owns its own `Configure()`, seeding from the existing `insurance.enable` setting (default `Core.AOS`), so no config migration is needed. `Mobile.InsuranceEnabled` is gone, along with its line in `ExpansionConfiguration`. * `CanInsure`, `GetInsuranceCost`, `ToggleItemInsurance`, `AutoRenewInventoryInsurance`, `CancelRenewInventoryInsurance` and `OpenItemInsuranceMenu` move here from `PlayerMobile`, which keeps four one-line shims for the context-menu callbacks. * Every entry point is gated on `Insurance.Enabled`, and the death-time state is only allocated when insurance is on — a shard without insurance pays nothing for it. **Feature flag integration** Insurance is now a first-class feature flag: `ServerFeatureFlags.InsuranceEnabled`, registered under the `insurance` key in `FeatureFlagManager.SyncStaticFlag`, so it can be inspected and toggled through the normal flag command/gump rather than only at boot. `Insurance.Enabled` reads through to the flag, so there is one source of truth for every consumer. **Fixes a memory leak from PvP** `PlayerMobile.m_InsuranceAward` was a `Mobile` field assigned on every death and never cleared, so every player permanently pinned a strong reference to the last player who killed them. Killers were kept alive by their victims indefinitely. Death-time insurance state now lives in a `Dictionary` owned by the insurance system: the entry is created in `OnBeforeDeath` and removed in `OnDeath`, so nothing outlives the death that created it. **Removes insurance fields from every PlayerMobile** `m_InsuranceAward`, `m_InsuranceBonus` and `m_NonAutoreinsuredItems` were carried by every `PlayerMobile` whether or not the shard ran insurance. All three are gone; the equivalent state is allocated per-death, only for players who actually die with insured items, only when insurance is enabled. **Stale `Insured` flags are inert when insurance is off** `Item.Insured` is a persisted flag, so items stay marked after a shard turns insurance off. Every read path now checks the flag first, so those items behave exactly as if they were never insured: * `Item.CheckBlessed` / `Item.IsStandardLoot` — they drop again instead of acting blessed * `Item.AddLootTypeProperty` — no more phantom "insured" tooltip * `PlayerMobile.FindItems_Callback` — not yanked out of nested bags on death * `DestroyEquipment` — no longer immune * `ClothingBlessDeed` — no longer reports "that item is already blessed" **Gumps promoted out of `PlayerMobile`** `ItemInsuranceMenuGump`, `ItemInsuranceMenuConfirmGump` and `CancelRenewInventoryInsuranceGump` were private nested classes reaching into `PlayerMobile` privates. They are now public types in `Engines/Insurance/Gumps/`, talking to the insurance system through its public API. `ItemInsuranceMenuGump.ToggleSelected()` replaces the confirm gump's reach-in to the parent's `_items`/`_insure` arrays. ### Behavior changes * The per-item "You lack the funds to purchase the insurance" message on failed auto-renewal is no longer sent during death; players get the single 1061115 summary instead. Marked with a TODO pending a decision on whether the per-item message should spam. * The killer's insurance bonus is deposited once at the end of death processing rather than 300 gold at a time per insured item, and the "gold has been deposited" message is now conditional on the deposit succeeding. Same total. ### Drive-by cleanups `PoisonImpl.IncreaseLevel` -> `Poison.IncreaseLevel`, a redundant `is NetState { } ns` pattern, `new List(Items)` -> collection expression, alignment of the `SyncStaticFlag` switch arms, and some comment/formatting fixes in `PlayerMobile`. --- Projects/Server/FeatureFlags.cs | 1 + Projects/Server/Items/Item.cs | 6 +- Projects/Server/Mobiles/Mobile.cs | 2 - .../Configuration/ExpansionConfiguration.cs | 1 - .../FeatureFlags/FeatureFlagManager.cs | 25 +- .../CancelRenewInventoryInsuranceGump.cs | 55 ++ .../Gumps/ItemInsuranceMenuConfirmGump.cs | 46 ++ .../Insurance/Gumps/ItemInsuranceMenuGump.cs | 207 ++++++ .../UOContent/Engines/Insurance/Insurance.cs | 323 +++++++++ .../Items/Deeds/ClothingBlessDeed.cs | 3 +- .../Mobiles/Abilities/DestroyEquipment.cs | 3 +- Projects/UOContent/Mobiles/PlayerMobile.cs | 635 +----------------- 12 files changed, 685 insertions(+), 622 deletions(-) create mode 100644 Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs create mode 100644 Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs create mode 100644 Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs create mode 100644 Projects/UOContent/Engines/Insurance/Insurance.cs diff --git a/Projects/Server/FeatureFlags.cs b/Projects/Server/FeatureFlags.cs index 99d6f9226..a70b4e452 100644 --- a/Projects/Server/FeatureFlags.cs +++ b/Projects/Server/FeatureFlags.cs @@ -10,4 +10,5 @@ public static class ServerFeatureFlags public static bool PvPCombat { get; set; } = true; public static bool BankAccess { get; set; } = true; public static bool SpeedhackDetection { get; set; } + public static bool InsuranceEnabled { get; set; } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index a3e546bc2..4d5ee13b3 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -1884,7 +1884,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { list.Add(1049643); // cursed } - else if (Insured) + else if (ServerFeatureFlags.InsuranceEnabled && Insured) { list.Add(1061682); // insured } @@ -4216,12 +4216,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } public virtual bool CheckBlessed(Mobile m) => - m_LootType == LootType.Blessed || Mobile.InsuranceEnabled && Insured || m != null && m == BlessedFor; + m_LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && Insured || m != null && m == BlessedFor; public virtual bool CheckNewbied() => m_LootType == LootType.Newbied; public virtual bool IsStandardLoot() => - (!Mobile.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; + (!ServerFeatureFlags.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; public override string ToString() => $"{Serial} \"{GetType().Name}\""; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 3e19548c6..34daaf796 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -953,8 +953,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static TimeSpan AutoManifestTimeout { get; set; } = TimeSpan.FromSeconds(5.0); - public static bool InsuranceEnabled { get; set; } - public static int ActionDelay { get; set; } = 500; public static VisibleDamageType VisibleDamageType { get; set; } diff --git a/Projects/UOContent/Configuration/ExpansionConfiguration.cs b/Projects/UOContent/Configuration/ExpansionConfiguration.cs index 3ae24bbca..50fec1dc3 100644 --- a/Projects/UOContent/Configuration/ExpansionConfiguration.cs +++ b/Projects/UOContent/Configuration/ExpansionConfiguration.cs @@ -7,7 +7,6 @@ namespace Server { public static void Configure() { - Mobile.InsuranceEnabled = ServerConfiguration.GetSetting("insurance.enable", Core.AOS); ObjectPropertyList.Enabled = ServerConfiguration.GetSetting("opl.enable", Core.AOS); var visibleDamage = ServerConfiguration.GetSetting("visibleDamage", Core.AOS); Mobile.VisibleDamageType = visibleDamage ? VisibleDamageType.Related : VisibleDamageType.None; diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs index 0402ad9a1..e599aeeb6 100644 --- a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs @@ -962,20 +962,21 @@ public static class FeatureFlagManager _ = key.ToLowerInvariant() switch { // Server project flags - "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, - "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, - "bank_access" => ServerFeatureFlags.BankAccess = enabled, - "speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled, + "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, + "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, + "bank_access" => ServerFeatureFlags.BankAccess = enabled, + "speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled, + "insurance" => ServerFeatureFlags.InsuranceEnabled = enabled, // UOContent flags - "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, - "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, - "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, - "house_placement" => ContentFeatureFlags.HousePlacement = enabled, - "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, - "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, - "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, - "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, + "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, + "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, + "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, + "house_placement" => ContentFeatureFlags.HousePlacement = enabled, + "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, + "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, + "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, + "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, "bitmap_pathfinding_cache" => ContentFeatureFlags.BitmapPathfindingCache = enabled, }; } diff --git a/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs new file mode 100644 index 000000000..e5cad5e68 --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs @@ -0,0 +1,55 @@ +using Server.Mobiles; +using Server.Network; + +namespace Server.Gumps; + +public class CancelRenewInventoryInsuranceGump : StaticGump +{ + private readonly ItemInsuranceMenuGump _insuranceGump; + + public override bool Singleton => true; + + public CancelRenewInventoryInsuranceGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => + _insuranceGump = insuranceGump; + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.AddBackground(0, 0, 240, 142, 0x13BE); + builder.AddImageTiled(6, 6, 228, 100, 0xA40); + builder.AddImageTiled(6, 116, 228, 20, 0xA40); + builder.AddAlphaRegion(6, 6, 228, 142); + + // You are about to disable inventory insurance auto-renewal. + builder.AddHtmlLocalized(8, 8, 228, 100, 1071021, 0x7FFF); + + builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL + + builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); + builder.AddHtmlLocalized(148, 118, 450, 20, 1071022, 0x7FFF); // DISABLE IT! + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) + { + return; + } + + if (info.ButtonID == 1) + { + // You have cancelled automatically reinsuring all insured items upon death + pm.SendLocalizedMessage(1061075, "", 0x23); + pm.AutoRenewInsurance = false; + } + else + { + pm.SendLocalizedMessage(1042021); // Cancelled. + } + + if (_insuranceGump != null) + { + pm.SendGump(_insuranceGump); + } + } +} diff --git a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs new file mode 100644 index 000000000..4fd597cf6 --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs @@ -0,0 +1,46 @@ +using Server.Mobiles; +using Server.Network; + +namespace Server.Gumps; + +public class ItemInsuranceMenuConfirmGump : StaticGump +{ + private readonly ItemInsuranceMenuGump _insuranceGump; + + public ItemInsuranceMenuConfirmGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => + _insuranceGump = insuranceGump; + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.AddBackground(0, 0, 240, 142, 0x13BE); + builder.AddImageTiled(6, 6, 228, 100, 0xA40); + builder.AddImageTiled(6, 116, 228, 20, 0xA40); + builder.AddAlphaRegion(6, 6, 228, 142); + + builder.AddHtmlLocalized(8, 8, 228, 100, 1114300, 0x7FFF); // Do you wish to insure all newly selected items? + + builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL + + builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); + builder.AddHtmlLocalized(148, 118, 450, 20, 1073996, 0x7FFF); // ACCEPT + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) + { + return; + } + + if (info.ButtonID == 1) + { + _insuranceGump.ToggleSelected(); + } + else + { + pm.SendLocalizedMessage(1042021); // Cancelled. + pm.SendGump(_insuranceGump); + } + } +} diff --git a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs new file mode 100644 index 000000000..2d17f63ab --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs @@ -0,0 +1,207 @@ +using Server.Engines.Insurance; +using Server.Mobiles; +using Server.Network; + +namespace Server.Gumps; + +public class ItemInsuranceMenuGump : DynamicGump +{ + private readonly PlayerMobile _from; + private readonly bool[] _insure; + private readonly Item[] _items; + private int _page; + + public override bool Singleton => true; + + public ItemInsuranceMenuGump(PlayerMobile from, Item[] items) : base(25, 50) + { + _from = from; + _items = items; + _insure = new bool[items.Length]; + + for (var i = 0; i < items.Length; ++i) + { + _insure[i] = items[i].Insured; + } + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + + builder.AddBackground(0, 0, 520, 510, 0x13BE); + builder.AddImageTiled(10, 10, 500, 30, 0xA40); + builder.AddImageTiled(10, 50, 500, 355, 0xA40); + builder.AddImageTiled(10, 415, 500, 80, 0xA40); + builder.AddAlphaRegion(10, 10, 500, 485); + + builder.AddButton(15, 470, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(50, 472, 80, 20, 1011012, 0x7FFF); // CANCEL + + if (_from.AutoRenewInsurance) + { + builder.AddButton(360, 10, 9723, 9724, 1); + } + else + { + builder.AddButton(360, 10, 9720, 9722, 1); + } + + builder.AddHtmlLocalized(395, 14, 105, 20, 1114122, 0x7FFF); // AUTO REINSURE + + builder.AddButton(395, 470, 0xFA5, 0xFA6, 2); + builder.AddHtmlLocalized(430, 472, 50, 20, 1006044, 0x7FFF); // OK + + builder.AddHtmlLocalized(10, 14, 150, 20, 1114121, 0x7FFF); //
ITEM INSURANCE MENU
+ + builder.AddHtmlLocalized(45, 54, 70, 20, 1062214, 0x7FFF); // Item + builder.AddHtmlLocalized(250, 54, 70, 20, 1061038, 0x7FFF); // Cost + builder.AddHtmlLocalized(400, 54, 70, 20, 1114311, 0x7FFF); // Insured + + var balance = Banker.GetBalance(_from); + var cost = 0; + + for (var i = 0; i < _items.Length; ++i) + { + if (_insure[i]) + { + cost += Insurance.GetInsuranceCost(_from, _items[i]); + } + } + + builder.AddHtmlLocalized(15, 420, 300, 20, 1114310, 0x7FFF); // GOLD AVAILABLE: + builder.AddLabel(215, 420, 0x481, $"{balance}"); + builder.AddHtmlLocalized(15, 435, 300, 20, 1114123, 0x7FFF); // TOTAL COST OF INSURANCE: + builder.AddLabel(215, 435, 0x481, $"{cost}"); + + if (cost != 0) + { + builder.AddHtmlLocalized(15, 450, 300, 20, 1114125, 0x7FFF); // NUMBER OF DEATHS PAYABLE: + builder.AddLabel(215, 450, 0x481, $"{balance / cost}"); + } + + for (int i = _page * 4, y = 72; i < (_page + 1) * 4 && i < _items.Length; ++i, y += 75) + { + var item = _items[i]; + var b = ItemBounds.Bounds[item.ItemID]; + + builder.AddImageTiledButton( + 40, + y, + 0x918, + 0x918, + 0, + GumpButtonType.Page, + 0, + item.ItemID, + item.Hue, + 40 - b.Width / 2 - b.X, + 30 - b.Height / 2 - b.Y + ); + builder.AddItemProperty(item.Serial); + + if (_insure[i]) + { + builder.AddButton(400, y, 9723, 9724, 100 + i); + builder.AddLabel(250, y, 0x481, $"{Insurance.GetInsuranceCost(_from, item)}"); + } + else + { + builder.AddButton(400, y, 9720, 9722, 100 + i); + builder.AddLabel(250, y, 0x66C, $"{Insurance.GetInsuranceCost(_from, item)}"); + } + } + + if (_page >= 1) + { + builder.AddButton(15, 380, 0xFAE, 0xFAF, 3); + builder.AddHtmlLocalized(50, 380, 450, 20, 1044044, 0x7FFF); // PREV PAGE + } + + if ((_page + 1) * 4 < _items.Length) + { + builder.AddButton(400, 380, 0xFA5, 0xFA7, 4); + builder.AddHtmlLocalized(435, 380, 70, 20, 1044045, 0x7FFF); // NEXT PAGE + } + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (info.ButtonID == 0 || !_from.CheckAlive()) + { + return; + } + + switch (info.ButtonID) + { + case 1: // Auto Reinsure + { + if (_from.AutoRenewInsurance) + { + _from.SendGump(new CancelRenewInventoryInsuranceGump(this)); + } + else + { + Insurance.AutoRenewInventoryInsurance(_from); + _from.SendGump(this); + } + + break; + } + case 2: // OK + { + _from.SendGump(new ItemInsuranceMenuConfirmGump(this)); + + break; + } + case 3: // Prev + { + if (_page >= 1) + { + _page--; + _from.SendGump(this); + } + + break; + } + case 4: // Next + { + if ((_page + 1) * 4 < _items.Length) + { + _page++; + _from.SendGump(this); + } + + break; + } + default: + { + var idx = info.ButtonID - 100; + + if (idx >= 0 && idx < _items.Length) + { + _insure[idx] = !_insure[idx]; + } + + _from.SendGump(this); + + break; + } + } + } + + public void ToggleSelected() + { + var items = _items; + var insure = _insure; + for (var i = 0; i < items.Length; ++i) + { + var item = items[i]; + + if (item.Insured != insure[i]) + { + Insurance.ToggleItemInsurance(_from, item, false); + } + } + } +} diff --git a/Projects/UOContent/Engines/Insurance/Insurance.cs b/Projects/UOContent/Engines/Insurance/Insurance.cs new file mode 100644 index 000000000..8ba3985e5 --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Insurance.cs @@ -0,0 +1,323 @@ +using System.Collections.Generic; +using Server.Collections; +using Server.Factions; +using Server.Gumps; +using Server.Items; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Engines.Insurance; + +public static class Insurance +{ + public static bool Enabled => ServerFeatureFlags.InsuranceEnabled; + private static readonly Dictionary _insuranceContexts = []; + + public static void Configure() + { + // Legacy, but not expected to be used anymore in favor of the feature flag system + ServerFeatureFlags.InsuranceEnabled = ServerConfiguration.GetSetting("insurance.enable", Core.AOS); + } + + public static int GetInsuranceCost(Mobile from, Item item) => 600; + + public static void CheckInsuranceBeforeDeath(Mobile from) + { + if (!Enabled) + { + return; + } + + var recentDamager = from.FindMostRecentDamager(false); + if (recentDamager == null) + { + return; + } + + if (recentDamager is BaseCreature creature) + { + recentDamager = creature.GetMaster(); + } + + if (recentDamager != from && recentDamager is PlayerMobile insuranceReward) + { + _insuranceContexts[from] = new InsuranceContext(insuranceReward); + } + } + + public static void CheckInsuranceOnDeath(Mobile from) + { + if (!Enabled || !_insuranceContexts.Remove(from, out var context)) + { + return; + } + + if (context.MissedAutoRenewal) + { + from.SendLocalizedMessage(1061115); // You do not have the gold to automatically reinsure all your items. + } + + if (context.InsuranceReward != null && context.InsuranceBonus > 0 && + Banker.Deposit(context.InsuranceReward, context.InsuranceBonus)) + { + // ~1_AMOUNT~ gold has been deposited into your bank box. + context.InsuranceReward.SendLocalizedMessage(1060397, $"{context.InsuranceBonus}"); + } + } + + public static bool CheckItemInsuranceOnDeath(PlayerMobile from, Item item) + { + if (!Enabled || !item.Insured) + { + return false; + } + + if (from.DuelContext?.Registered == true && from.DuelContext.Started && from.DuelPlayer?.Eliminated != true) + { + return true; + } + + if (!_insuranceContexts.TryGetValue(from, out var context)) + { + _insuranceContexts[from] = context = new InsuranceContext(); + } + + if (from.AutoRenewInsurance) + { + var cost = Insurance.GetInsuranceCost(from, item); + + if (context.InsuranceReward != null) + { + cost /= 2; + } + + if (Banker.Withdraw(from, cost)) + { + item.PaidInsurance = true; + // ~1_AMOUNT~ gold has been withdrawn from your bank box. + from.SendLocalizedMessage(1060398, $"{cost}"); + } + else + { + // TODO: Should this spam? + // from.SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance + item.PaidInsurance = false; + item.Insured = false; + context.MissedAutoRenewal = true; + } + } + else + { + item.PaidInsurance = false; + item.Insured = false; + } + + context.InsuranceBonus += 300; + return true; + } + + public static bool CanInsure(Mobile from, Item item) + { + if (!Enabled) + { + return false; + } + + if (item is Container && item is not BaseQuiver || item is BagOfSending or KeyRing or PotionKeg or Sigil) + { + return false; + } + + if (item.Stackable) + { + return false; + } + + if (item.LootType == LootType.Cursed) + { + return false; + } + + if (item.ItemID == 0x204E) // death shroud + { + return false; + } + + if (item.Layer == Layer.Mount) + { + return false; + } + + return item.LootType != LootType.Blessed && item.LootType != LootType.Newbied && item.BlessedFor != from; + } + + public static void ToggleItemInsurance(Mobile from) + { + if (!from.CheckAlive()) + { + return; + } + + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + from.SendLocalizedMessage(1060868); // Target the item you wish to toggle insurance status on to cancel + } + + public static void ToggleItemInsurance(Mobile from, object obj) + { + if (!from.CheckAlive()) + { + return; + } + + ToggleItemInsurance(from, obj as Item, true); + } + + public static void ToggleItemInsurance(Mobile from, Item item, bool target) + { + if (item?.IsChildOf(from) != true) + { + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + } + + // You can only insure items that you have equipped or that are in your backpack + from.SendLocalizedMessage(1060871, "", 0x23); + } + else if (item.Insured) + { + item.Insured = false; + + from.SendLocalizedMessage(1060874, "", 0x35); // You cancel the insurance on the item + + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + // Target the item you wish to toggle insurance status on to cancel + from.SendLocalizedMessage(1060868, "", 0x23); + } + } + else if (!CanInsure(from, item)) + { + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + } + + from.SendLocalizedMessage(1060869, "", 0x23); // You cannot insure that + } + else + { + if (!item.PaidInsurance) + { + var cost = GetInsuranceCost(from, item); + + if (Banker.Withdraw(from, cost)) + { + // ~1_AMOUNT~ gold has been withdrawn from your bank box. + from.SendLocalizedMessage(1060398, $"{cost}"); + item.PaidInsurance = true; + } + else + { + from.SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance + return; + } + } + + item.Insured = true; + + from.SendLocalizedMessage(1060873, "", 0x23); // You have insured the item + + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + // Target the item you wish to toggle insurance status on to cancel + from.SendLocalizedMessage(1060868, "", 0x23); + } + } + } + + public static void AutoRenewInventoryInsurance(Mobile from) + { + if (!from.CheckAlive()) + { + return; + } + + // You have selected to automatically reinsure all insured items upon death + from.SendLocalizedMessage(1060881, "", 0x23); + (from as PlayerMobile)?.AutoRenewInsurance = true; + } + + public static void CancelRenewInventoryInsurance(Mobile from) + { + if (!from.CheckAlive()) + { + return; + } + + if (Core.SE) + { + from.SendGump(new CancelRenewInventoryInsuranceGump(null)); + } + else + { + // You have cancelled automatically reinsuring all insured items upon death + from.SendLocalizedMessage(1061075, "", 0x23); + (from as PlayerMobile)?.AutoRenewInsurance = false; + } + } + + public static void OpenItemInsuranceMenu(Mobile from) + { + if (!from.CheckAlive() || from.NetState == null) + { + return; + } + + using var queue = PooledRefQueue.Create(128); + + foreach (var item in from.Items) + { + if (DisplayInItemInsuranceGump(from, item)) + { + queue.Enqueue(item); + } + } + + var pack = from.Backpack; + + if (pack != null) + { + foreach (var item in pack.FindItems()) + { + if (DisplayInItemInsuranceGump(from, item)) + { + queue.Enqueue(item); + } + } + } + + if (queue.Count == 0) + { + // None of your current items meet the requirements for insurance. + from.SendLocalizedMessage(1114915, "", 0x35); + } + else if (from is PlayerMobile pm) + { + // TODO: Investigate item sorting + from.SendGump(new ItemInsuranceMenuGump(pm, queue.ToArray())); + } + } + + private static bool DisplayInItemInsuranceGump(Mobile from, Item item) => + (item.Visible || from.AccessLevel >= AccessLevel.GameMaster) && (item.Insured || CanInsure(from, item)); + + private class InsuranceContext(PlayerMobile insuranceReward = null) + { + public PlayerMobile InsuranceReward = insuranceReward; + public int InsuranceBonus { get; set; } + public bool MissedAutoRenewal { get; set; } + } +} diff --git a/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs b/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs index 20f8f9fc6..25860f542 100644 --- a/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs +++ b/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs @@ -1,4 +1,5 @@ using ModernUO.Serialization; +using Server.Engines.Insurance; using Server.Targeting; namespace Server.Items; @@ -25,7 +26,7 @@ public class ClothingBlessTarget : Target // Create our targeting class (which w } // Check if its already newbied (blessed) - if (item.LootType == LootType.Blessed || item.BlessedFor == from || Mobile.InsuranceEnabled && item.Insured) + if (item.LootType == LootType.Blessed || item.BlessedFor == from || Insurance.Enabled && item.Insured) { from.SendLocalizedMessage(1045113); // That item is already blessed } diff --git a/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs b/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs index c74698d7c..ff7937ded 100644 --- a/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs +++ b/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs @@ -1,4 +1,5 @@ using Server.Collections; +using Server.Engines.Insurance; using Server.Items; namespace Server.Mobiles; @@ -23,7 +24,7 @@ public class DestroyEquipment : MonsterAbilitySingleTarget continue; } - if (Mobile.InsuranceEnabled && item.Insured) + if (Insurance.Enabled && item.Insured) { continue; } diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 0a81bb846..83c0e4cca 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -11,6 +11,7 @@ using Server.Engines.CannedEvil; using Server.Engines.ConPVP; using Server.Engines.Craft; using Server.Engines.Help; +using Server.Engines.Insurance; using Server.Engines.MLQuests; using Server.Engines.MLQuests.Gumps; using Server.Engines.PartySystem; @@ -161,7 +162,7 @@ namespace Server.Mobiles /* * a value of zero means, that the mobile is not executing the spell. Otherwise, * the value should match the BaseMana required - */ + */ private RankDefinition m_GuildRank; @@ -169,9 +170,6 @@ namespace Server.Mobiles public DateTime _honorTime; - private Mobile m_InsuranceAward; - private int m_InsuranceBonus; - private int m_LastGlobalLight = -1, m_LastPersonalLight = -1; private bool m_LastProtectedMessage; @@ -190,9 +188,6 @@ namespace Server.Mobiles private bool m_NoDeltaRecursion; - // number of items that could not be automatically reinsured because gold in bank was not enough - private int m_NonAutoreinsuredItems; - private DateTime m_SavagePaintExpiration; private DateTime[] m_StuckMenuUses; @@ -908,17 +903,17 @@ namespace Server.Mobiles { Direction.North => itemIDs[0], Direction.South => itemIDs[0], - Direction.East => itemIDs[1], - Direction.West => itemIDs[1], - _ => item.ItemID + Direction.East => itemIDs[1], + Direction.West => itemIDs[1], + _ => item.ItemID }, 4 => dir switch { Direction.South => itemIDs[0], - Direction.East => itemIDs[1], + Direction.East => itemIDs[1], Direction.North => itemIDs[2], - Direction.West => itemIDs[3], - _ => item.ItemID + Direction.West => itemIDs[3], + _ => item.ItemID }, _ => item.ItemID }; @@ -989,7 +984,7 @@ namespace Server.Mobiles } if (skillId == 35) - // AnimalTaming.DeferredTarget = true; + // AnimalTaming.DeferredTarget = true; { AnimalTaming.DisableMessage = false; } @@ -1845,7 +1840,7 @@ namespace Server.Mobiles // moving, not teleporting var zDrop = Location.Z - loc.Z; - if (zDrop > 20) // we fell more than one story + if (zDrop > 20) // we fell more than one story { Hits -= zDrop / 20 * 10 - 5; // deal some damage; does not kill, disrupt, etc } @@ -1874,7 +1869,7 @@ namespace Server.Mobiles if (Alive) { - if (InsuranceEnabled) + if (Insurance.Enabled) { if (Core.SA) { @@ -2002,7 +1997,7 @@ namespace Server.Mobiles { var house = BaseHouse.FindHouseAt(this); - if (CheckAlive() && house?.IsOwner(this) == true && house.InternalizedVendors.Count > 0 && NetState is NetState { } ns) + if (CheckAlive() && house?.IsOwner(this) == true && house.InternalizedVendors.Count > 0 && NetState != null) { ReclaimVendorGump.DisplayTo(this, house); } @@ -2310,7 +2305,8 @@ namespace Server.Mobiles pm.DuelPlayer.Eliminated) || base.OnMoveOver(m); public override bool CheckShove(Mobile shoved) => - IgnoreMobiles || shoved.IgnoreMobiles || TransformationSpellHelper.UnderTransformation(shoved, typeof(WraithFormSpell)) || + IgnoreMobiles || shoved.IgnoreMobiles || + TransformationSpellHelper.UnderTransformation(shoved, typeof(WraithFormSpell)) || base.CheckShove(shoved); protected override void OnMapChange(Map oldMap) @@ -2401,7 +2397,8 @@ namespace Server.Mobiles [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool FindItems_Callback(Item item) => - !item.Deleted && (item.LootType == LootType.Blessed || item.Insured) && Backpack != item.Parent; + !item.Deleted && Backpack != item.Parent && + (item.LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && item.Insured); public override bool OnBeforeDeath() { @@ -2422,30 +2419,8 @@ namespace Server.Mobiles } } - EquipSnapshot = new List(Items); - - m_NonAutoreinsuredItems = 0; - m_InsuranceAward = FindMostRecentDamager(false); - - if (m_InsuranceAward is BaseCreature creature) - { - var master = creature.GetMaster(); - - if (master != null) - { - m_InsuranceAward = master; - } - } - - if (m_InsuranceAward != null && (!m_InsuranceAward.Player || m_InsuranceAward == this)) - { - m_InsuranceAward = null; - } - - if (m_InsuranceAward is PlayerMobile mobile) - { - mobile.m_InsuranceBonus = 0; - } + EquipSnapshot = [..Items]; + Insurance.CheckInsuranceBeforeDeath(this); ReceivedHonorContext?.OnTargetKilled(); SentHonorContext?.OnSourceKilled(); @@ -2453,56 +2428,6 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - private bool CheckInsuranceOnDeath(Item item) - { - if (!InsuranceEnabled || !item.Insured) - { - return false; - } - - if (DuelContext?.Registered == true && DuelContext.Started && - m_DuelPlayer?.Eliminated != true) - { - return true; - } - - if (AutoRenewInsurance) - { - var cost = GetInsuranceCost(item); - - if (m_InsuranceAward != null) - { - cost /= 2; - } - - if (Banker.Withdraw(this, cost)) - { - item.PaidInsurance = true; - // ~1_AMOUNT~ gold has been withdrawn from your bank box. - SendLocalizedMessage(1060398, cost.ToString()); - } - else - { - SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance - item.PaidInsurance = false; - item.Insured = false; - m_NonAutoreinsuredItems++; - } - } - else - { - item.PaidInsurance = false; - item.Insured = false; - } - - if (m_InsuranceAward is PlayerMobile insurancePm && Banker.Deposit(m_InsuranceAward, 300)) - { - insurancePm.m_InsuranceBonus += 300; - } - - return true; - } - public override DeathMoveResult GetParentMoveResultFor(Item item) { // It seems all items are unmarked on death, even blessed/insured ones @@ -2511,7 +2436,7 @@ namespace Server.Mobiles item.QuestItem = false; } - if (CheckInsuranceOnDeath(item)) + if (Insurance.CheckItemInsuranceOnDeath(this, item)) { return DeathMoveResult.MoveToBackpack; } @@ -2534,7 +2459,7 @@ namespace Server.Mobiles item.QuestItem = false; } - if (CheckInsuranceOnDeath(item)) + if (Insurance.CheckItemInsuranceOnDeath(this, item)) { return DeathMoveResult.MoveToBackpack; } @@ -2554,11 +2479,6 @@ namespace Server.Mobiles public override void OnDeath(Container c) { - if (m_NonAutoreinsuredItems > 0) - { - SendLocalizedMessage(1061115); - } - base.OnDeath(c); EquipSnapshot = null; @@ -2627,11 +2547,7 @@ namespace Server.Mobiles } } - if (m_InsuranceAward is PlayerMobile insurancePm && insurancePm.m_InsuranceBonus > 0) - { - // ~1_AMOUNT~ gold has been deposited into your bank box. - insurancePm.SendLocalizedMessage(1060397, insurancePm.m_InsuranceBonus.ToString()); - } + Insurance.CheckInsuranceOnDeath(this); var killer = FindMostRecentDamager(true); @@ -2911,7 +2827,8 @@ namespace Server.Mobiles for (var i = 0; i < recipeCount; i++) { var r = reader.ReadInt(); - if (version > 33 || reader.ReadBool()) // Don't add in recipes which we haven't gotten or have been removed + // Don't add in recipes which we haven't gotten or have been removed + if (version > 33 || reader.ReadBool()) { _acquiredRecipes.Add(r); } @@ -2926,6 +2843,7 @@ namespace Server.Mobiles { reader.ReadDeltaTime(); // LastHonorLoss - Not even used } + goto case 23; } case 23: @@ -3021,6 +2939,7 @@ namespace Server.Mobiles { virtues.LastCompassionLoss = reader.ReadDeltaTime(); } + goto case 14; } case 14: @@ -3710,213 +3629,13 @@ namespace Server.Mobiles AutoStabled = null; } - private static int GetInsuranceCost(Item item) => 600; + private void ToggleItemInsurance() => Insurance.ToggleItemInsurance(this); - private void ToggleItemInsurance() - { - if (!CheckAlive()) - { - return; - } + private void OpenItemInsuranceMenu() => Insurance.OpenItemInsuranceMenu(this); - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - SendLocalizedMessage(1060868); // Target the item you wish to toggle insurance status on to cancel - } + private void CancelRenewInventoryInsurance() => Insurance.CancelRenewInventoryInsurance(this); - private bool CanInsure(Item item) - { - if (item is Container && item is not BaseQuiver || item is BagOfSending or KeyRing or PotionKeg or Sigil) - { - return false; - } - - if (item.Stackable) - { - return false; - } - - if (item.LootType == LootType.Cursed) - { - return false; - } - - if (item.ItemID == 0x204E) // death shroud - { - return false; - } - - if (item.Layer == Layer.Mount) - { - return false; - } - - return item.LootType != LootType.Blessed && item.LootType != LootType.Newbied && item.BlessedFor != this; - } - - private void ToggleItemInsurance_Callback(Mobile from, object obj) - { - if (!CheckAlive()) - { - return; - } - - ToggleItemInsurance_Callback(from, obj as Item, true); - } - - private void ToggleItemInsurance_Callback(Mobile from, Item item, bool target) - { - if (item?.IsChildOf(this) != true) - { - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - } - - SendLocalizedMessage( - 1060871, - "", - 0x23 - ); // You can only insure items that you have equipped or that are in your backpack - } - else if (item.Insured) - { - item.Insured = false; - - SendLocalizedMessage(1060874, "", 0x35); // You cancel the insurance on the item - - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - SendLocalizedMessage( - 1060868, - "", - 0x23 - ); // Target the item you wish to toggle insurance status on to cancel - } - } - else if (!CanInsure(item)) - { - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - } - - SendLocalizedMessage(1060869, "", 0x23); // You cannot insure that - } - else - { - if (!item.PaidInsurance) - { - var cost = GetInsuranceCost(item); - - if (Banker.Withdraw(from, cost)) - { - SendLocalizedMessage( - 1060398, - cost.ToString() - ); // ~1_AMOUNT~ gold has been withdrawn from your bank box. - item.PaidInsurance = true; - } - else - { - SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance - return; - } - } - - item.Insured = true; - - SendLocalizedMessage(1060873, "", 0x23); // You have insured the item - - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - SendLocalizedMessage( - 1060868, - "", - 0x23 - ); // Target the item you wish to toggle insurance status on to cancel - } - } - } - - private void AutoRenewInventoryInsurance() - { - if (!CheckAlive()) - { - return; - } - - // You have selected to automatically reinsure all insured items upon death - SendLocalizedMessage(1060881, "", 0x23); - AutoRenewInsurance = true; - } - - private void CancelRenewInventoryInsurance() - { - if (!CheckAlive()) - { - return; - } - - if (Core.SE) - { - NetState?.SendGump(new CancelRenewInventoryInsuranceGump(null)); - } - else - { - // You have cancelled automatically reinsuring all insured items upon death - SendLocalizedMessage(1061075, "", 0x23); - AutoRenewInsurance = false; - } - } - - private void OpenItemInsuranceMenu() - { - if (!CheckAlive()) - { - return; - } - - using var queue = PooledRefQueue.Create(128); - - foreach (var item in Items) - { - if (DisplayInItemInsuranceGump(item)) - { - queue.Enqueue(item); - } - } - - var pack = Backpack; - - if (pack != null) - { - foreach (var item in pack.FindItems()) - { - if (DisplayInItemInsuranceGump(item)) - { - queue.Enqueue(item); - } - } - } - - // TODO: Investigate item sorting - if (NetState != null) - { - if (queue.Count == 0) - { - SendLocalizedMessage(1114915, "", 0x35); // None of your current items meet the requirements for insurance. - } - else - { - NetState.SendGump(new ItemInsuranceMenuGump(this, queue.ToArray())); - } - } - } - - private bool DisplayInItemInsuranceGump(Item item) => (item.Visible || AccessLevel >= AccessLevel.GameMaster) && - (item.Insured || CanInsure(item)); + private void AutoRenewInventoryInsurance() => Insurance.AutoRenewInventoryInsurance(this); private void ToggleQuestItem() { @@ -4022,7 +3741,7 @@ namespace Server.Mobiles if (EvilOmenSpell.EndEffect(this)) { - poison = PoisonImpl.IncreaseLevel(poison); + poison = Poison.IncreaseLevel(poison); } var result = base.ApplyPoison(from, poison); @@ -4473,7 +4192,9 @@ namespace Server.Mobiles var offset = duration.TotalMilliseconds - roundedSeconds * TimeSpan.MillisecondsPerSecond; if (offset > 0) { - Timer.DelayCall(TimeSpan.FromMilliseconds(offset), () => + Timer.DelayCall( + TimeSpan.FromMilliseconds(offset), + () => { // They are still online, we still have the buff icon in the table, and it is the same buff icon if (NetState != null && m_BuffTable?.GetValueOrDefault(buffInfo.ID) == buffInfo) @@ -4598,295 +4319,5 @@ namespace Server.Mobiles m_Callback?.Invoke(); } } - - private class CancelRenewInventoryInsuranceGump : StaticGump - { - private readonly ItemInsuranceMenuGump _insuranceGump; - - public override bool Singleton => true; - - public CancelRenewInventoryInsuranceGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => - _insuranceGump = insuranceGump; - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.AddBackground(0, 0, 240, 142, 0x13BE); - builder.AddImageTiled(6, 6, 228, 100, 0xA40); - builder.AddImageTiled(6, 116, 228, 20, 0xA40); - builder.AddAlphaRegion(6, 6, 228, 142); - - // You are about to disable inventory insurance auto-renewal. - builder.AddHtmlLocalized(8, 8, 228, 100, 1071021, 0x7FFF); - - builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL - - builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); - builder.AddHtmlLocalized(148, 118, 450, 20, 1071022, 0x7FFF); // DISABLE IT! - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) - { - return; - } - - if (info.ButtonID == 1) - { - // You have cancelled automatically reinsuring all insured items upon death - pm.SendLocalizedMessage(1061075, "", 0x23); - pm.AutoRenewInsurance = false; - } - else - { - pm.SendLocalizedMessage(1042021); // Cancelled. - } - - if (_insuranceGump != null) - { - pm.SendGump(_insuranceGump); - } - } - } - - private class ItemInsuranceMenuGump : DynamicGump - { - private readonly PlayerMobile _from; - private readonly bool[] _insure; - private readonly Item[] _items; - private int _page; - - public override bool Singleton => true; - - public ItemInsuranceMenuGump(PlayerMobile from, Item[] items) : base(25, 50) - { - _from = from; - _items = items; - _insure = new bool[items.Length]; - - for (var i = 0; i < items.Length; ++i) - { - _insure[i] = items[i].Insured; - } - } - - protected override void BuildLayout(ref DynamicGumpBuilder builder) - { - builder.AddPage(); - - builder.AddBackground(0, 0, 520, 510, 0x13BE); - builder.AddImageTiled(10, 10, 500, 30, 0xA40); - builder.AddImageTiled(10, 50, 500, 355, 0xA40); - builder.AddImageTiled(10, 415, 500, 80, 0xA40); - builder.AddAlphaRegion(10, 10, 500, 485); - - builder.AddButton(15, 470, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(50, 472, 80, 20, 1011012, 0x7FFF); // CANCEL - - if (_from.AutoRenewInsurance) - { - builder.AddButton(360, 10, 9723, 9724, 1); - } - else - { - builder.AddButton(360, 10, 9720, 9722, 1); - } - - builder.AddHtmlLocalized(395, 14, 105, 20, 1114122, 0x7FFF); // AUTO REINSURE - - builder.AddButton(395, 470, 0xFA5, 0xFA6, 2); - builder.AddHtmlLocalized(430, 472, 50, 20, 1006044, 0x7FFF); // OK - - builder.AddHtmlLocalized(10, 14, 150, 20, 1114121, 0x7FFF); //
ITEM INSURANCE MENU
- - builder.AddHtmlLocalized(45, 54, 70, 20, 1062214, 0x7FFF); // Item - builder.AddHtmlLocalized(250, 54, 70, 20, 1061038, 0x7FFF); // Cost - builder.AddHtmlLocalized(400, 54, 70, 20, 1114311, 0x7FFF); // Insured - - var balance = Banker.GetBalance(_from); - var cost = 0; - - for (var i = 0; i < _items.Length; ++i) - { - if (_insure[i]) - { - cost += GetInsuranceCost(_items[i]); - } - } - - builder.AddHtmlLocalized(15, 420, 300, 20, 1114310, 0x7FFF); // GOLD AVAILABLE: - builder.AddLabel(215, 420, 0x481, balance.ToString()); - builder.AddHtmlLocalized(15, 435, 300, 20, 1114123, 0x7FFF); // TOTAL COST OF INSURANCE: - builder.AddLabel(215, 435, 0x481, cost.ToString()); - - if (cost != 0) - { - builder.AddHtmlLocalized(15, 450, 300, 20, 1114125, 0x7FFF); // NUMBER OF DEATHS PAYABLE: - builder.AddLabel(215, 450, 0x481, (balance / cost).ToString()); - } - - for (int i = _page * 4, y = 72; i < (_page + 1) * 4 && i < _items.Length; ++i, y += 75) - { - var item = _items[i]; - var b = ItemBounds.Bounds[item.ItemID]; - - builder.AddImageTiledButton( - 40, - y, - 0x918, - 0x918, - 0, - GumpButtonType.Page, - 0, - item.ItemID, - item.Hue, - 40 - b.Width / 2 - b.X, - 30 - b.Height / 2 - b.Y - ); - builder.AddItemProperty(item.Serial); - - if (_insure[i]) - { - builder.AddButton(400, y, 9723, 9724, 100 + i); - builder.AddLabel(250, y, 0x481, GetInsuranceCost(item).ToString()); - } - else - { - builder.AddButton(400, y, 9720, 9722, 100 + i); - builder.AddLabel(250, y, 0x66C, GetInsuranceCost(item).ToString()); - } - } - - if (_page >= 1) - { - builder.AddButton(15, 380, 0xFAE, 0xFAF, 3); - builder.AddHtmlLocalized(50, 380, 450, 20, 1044044, 0x7FFF); // PREV PAGE - } - - if ((_page + 1) * 4 < _items.Length) - { - builder.AddButton(400, 380, 0xFA5, 0xFA7, 4); - builder.AddHtmlLocalized(435, 380, 70, 20, 1044045, 0x7FFF); // NEXT PAGE - } - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (info.ButtonID == 0 || !_from.CheckAlive()) - { - return; - } - - switch (info.ButtonID) - { - case 1: // Auto Reinsure - { - if (_from.AutoRenewInsurance) - { - _from.SendGump(new CancelRenewInventoryInsuranceGump(this)); - } - else - { - _from.AutoRenewInventoryInsurance(); - _from.SendGump(this); - } - - break; - } - case 2: // OK - { - _from.SendGump(new ItemInsuranceMenuConfirmGump(this)); - - break; - } - case 3: // Prev - { - if (_page >= 1) - { - _page--; - _from.SendGump(this); - } - - break; - } - case 4: // Next - { - if ((_page + 1) * 4 < _items.Length) - { - _page++; - _from.SendGump(this); - } - - break; - } - default: - { - var idx = info.ButtonID - 100; - - if (idx >= 0 && idx < _items.Length) - { - _insure[idx] = !_insure[idx]; - } - - _from.SendGump(this); - - break; - } - } - } - - private class ItemInsuranceMenuConfirmGump : StaticGump - { - private readonly ItemInsuranceMenuGump _parentGump; - - public ItemInsuranceMenuConfirmGump(ItemInsuranceMenuGump parentGump) : base(250, 200) => - _parentGump = parentGump; - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.AddBackground(0, 0, 240, 142, 0x13BE); - builder.AddImageTiled(6, 6, 228, 100, 0xA40); - builder.AddImageTiled(6, 116, 228, 20, 0xA40); - builder.AddAlphaRegion(6, 6, 228, 142); - - builder.AddHtmlLocalized(8, 8, 228, 100, 1114300, 0x7FFF); // Do you wish to insure all newly selected items? - - builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL - - builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); - builder.AddHtmlLocalized(148, 118, 450, 20, 1073996, 0x7FFF); // ACCEPT - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) - { - return; - } - - if (info.ButtonID == 1) - { - var items = _parentGump._items; - var insure = _parentGump._insure; - for (var i = 0; i < items.Length; ++i) - { - var item = items[i]; - - if (item.Insured != insure[i]) - { - pm.ToggleItemInsurance_Callback(pm, item, false); - } - } - } - else - { - pm.SendLocalizedMessage(1042021); // Cancelled. - pm.SendGump(_parentGump); - } - } - } - } } } From 294dcd94a0ac23f86316187e6e9ff593e0879ade Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:06:53 -0700 Subject: [PATCH 22/64] fix: Fixes send-path backpressure: consume IORingGroup 1.0.8, stop dropping packets silently (#2551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two related fixes on the outbound path: 1. Consume **IORingGroup 1.0.8**, which allows more than one send in flight per socket, and expose the two settings that go with it. 2. Stop `NetState.Send` silently discarding packets when the send buffer fills — including an out-of-bounds write reachable in that state. ## 1. Send-path stall (RIO) RIO reports send completion on **acknowledgement**, not on copy, so a completion cannot arrive sooner than one round trip. With one send in flight, `PostSend` refused to post again until the previous completion arrived — capping a connection at **one send per RTT** whenever it had data queued. Measured on a 50ms-RTT production shard: | | before | after | |---|---|---| | in-game latency, data flowing | **101–146 ms** | **48–51 ms** | | p95 | ~135 ms | 52.8 ms | | samples > 70 ms | 20 | **0** | The control that confirms the mechanism: server-side post→completion was **unchanged** at median 92ms across both runs. The ACK-binding is inherent to RIO and did not move; only its propagation into application latency did. Two things worth recording, because they explain why this went unnoticed: - As little as **6 bytes** of queued data held the gate shut, so it reproduced in empty areas, not just crowded ones. - The same measurement at loopback RTT is **microseconds**, so local testing could never surface it. New settings, both restart-time: - **`network.maxOutstandingSends`** (default 32) — sends in flight per connection. Honoured by RIO only; other backends complete sends on copy and report 1. Costs a request-queue and completion-queue slot per send, **not another buffer**, since every outstanding send addresses a different range of the same registered buffer. Worst-case added latency is roughly `completion RTT / value`. - **`network.sendBufferSize`** (default 256KB) — per-connection send buffer, coerced to a power of two of at least the platform allocation granularity. This is the lever for the disconnects below, and the per-connection memory ceiling. ## 2. Send buffer full `NetState.Send` had three failure modes once the buffer filled, none of them visible: | writable | behaviour | |---|---| | `0` | `GetSendBuffer` returned false → **packet dropped**, no log, no disconnect | | `4 … needed-1` | `Compress` returned 0 → `CommitWrite(0)` → **packet dropped** the same way | | `1 … 3` | `safeOutputLength = (nuint)output.Length - 4` **underflows** → hot-loop bounds check never trips → **writes past the span** | The first two leave a client connected while quietly missing game state, which is undiagnosable from either end. The third corrupts the in-flight region of the ring buffer, and is reachable precisely when a connection is congested, since callers only check for non-zero space. `Compress` now refuses an output too small to bound, and `Send` reports exhaustion instead of dropping — logging and disconnecting with **needed / writable / unacked / capacity**. Those numbers separate a slow client holding the buffer from a buffer genuinely too small for the shard, which is the case that warrants raising `network.sendBufferSize`. ## Testing `NetworkCompressionBoundsTests` covers the underflow using sentinel bytes around the output window. **Verified to fail without the guard** (4 failures from overwritten sentinels), confirming the out-of-bounds writes were real rather than theoretical. Full suites green: **788 Server.Tests**, **597 UOContent.Tests**, Release build clean against the published 1.0.8. ## Notes for reviewers - Upstream change: modernuo/IORingGroup#9. - The buffer-full path is now *loud* where it used to be silent. If a shard has been quietly dropping packets under load, this will surface as disconnects — that is the intended outcome, and the log line says which setting to raise. - Follow-up under discussion: promoting a connection to a larger buffer instead of disconnecting, which looks feasible on a live connection since buffers are referenced per-operation rather than bound to the request queue. --- .../Network/NetworkCompressionBoundsTests.cs | 86 +++++++++++++++++++ .../Network/NetState/NetState.Network.cs | 57 ++++++++++-- Projects/Server/Network/NetState/NetState.cs | 46 +++++++++- Projects/Server/Network/NetworkCompression.cs | 4 +- Projects/Server/Server.csproj | 2 +- 5 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs diff --git a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs new file mode 100644 index 000000000..c1bc6a800 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs @@ -0,0 +1,86 @@ +using System; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Bounds behaviour of the Huffman compressor when the destination is too small. +/// +/// This is reachable in production: NetState only checks that the send buffer has *some* writable +/// space before handing the remainder to Compress, so a nearly-full buffer can offer a span of one +/// to three bytes. The internal guard is computed as an unsigned output.Length - 4, which +/// underflows for those sizes and stops bounding the writes at all. +/// +public class NetworkCompressionBoundsTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void RefusesOutputTooSmallToBound(int outputSize) + { + var input = new byte[64]; + Array.Fill(input, (byte)'A'); + + // Sentinel-filled backing array; only the middle window is offered to the compressor, so + // any write past the span shows up as a modified sentinel rather than silent corruption. + var backing = new byte[256]; + Array.Fill(backing, (byte)0xCC); + + const int windowStart = 64; + var output = backing.AsSpan(windowStart, outputSize); + + var written = NetworkCompression.Compress(input, output); + + Assert.Equal(0, written); + + for (var i = 0; i < backing.Length; i++) + { + Assert.Equal(0xCC, backing[i]); + } + } + + [Fact] + public void StillCompressesWhenOutputIsLargeEnough() + { + var input = new byte[64]; + Array.Fill(input, (byte)'A'); + + var output = new byte[256]; + + var written = NetworkCompression.Compress(input, output); + + Assert.True(written > 0); + Assert.True(written <= output.Length); + } + + [Fact] + public void ReportsFailureRatherThanOverrunningATightOutput() + { + // Large input against a small-but-bounded output: the guard is well-defined here, so this + // must fail cleanly rather than write past the end. + var input = new byte[4096]; + Array.Fill(input, (byte)'A'); + + var backing = new byte[256]; + Array.Fill(backing, (byte)0xCC); + + const int windowStart = 64; + const int windowSize = 16; + var output = backing.AsSpan(windowStart, windowSize); + + NetworkCompression.Compress(input, output); + + for (var i = 0; i < windowStart; i++) + { + Assert.Equal(0xCC, backing[i]); + } + + for (var i = windowStart + windowSize; i < backing.Length; i++) + { + Assert.Equal(0xCC, backing[i]); + } + } +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index b38c88d23..5262c3c2e 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -19,6 +19,7 @@ using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Network; +using System.Numerics; namespace Server.Network; @@ -28,9 +29,10 @@ namespace Server.Network; public partial class NetState { // Buffer sizes - private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers - private const int SendBufferSize = 1024 * 256; // 256KB send buffers - private const int MaxConnections = 4096; // Max concurrent connections + private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers + private const int DefaultSendBufferSize = 1024 * 256; // 256KB send buffers + private const int MinSendBufferSize = 1024 * 64; // Platform allocation granularity + private const int MaxConnections = 4096; // Max concurrent connections private static readonly Queue _disposed = []; private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds @@ -41,7 +43,9 @@ public partial class NetState // NetState storage indexed by RingSocket.Id private static readonly NetState[] _netStates = new NetState[MaxConnections]; - // Events buffer for ProcessCompletions + // Events buffer for ProcessCompletions. Bounded by one event per peeked completion + // (maxSockets), doubled for headroom. Undersizing drops DataReceived events whose bytes were + // already committed, leaving them unparsed until the next recv completes. private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2]; // Listener management @@ -88,20 +92,61 @@ public partial class NetState // Initialize IP rate limiter _ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token); + // Sends in flight per connection; honoured by RIO only (see IIORingGroup). Costs a + // request-queue and completion-queue slot per send, not another buffer. Worst-case added + // latency is roughly completion RTT / this value. + var maxOutstandingSends = ServerConfiguration.GetOrUpdateSetting("network.maxOutstandingSends", 32); + // Initialize IORingGroup - var ring = IORingGroup.Create(queueSize: MaxConnections * 2, maxConnections: MaxConnections); + var ring = IORingGroup.Create( + queueSize: MaxConnections * 2, + maxConnections: MaxConnections, + maxOutstandingSends: maxOutstandingSends + ); + + // Per-connection send buffer: the lever for "send buffer exhausted" disconnects, and the + // per-connection memory ceiling. + var sendBufferSize = GetSendBufferSize(); // Create socket manager which handles buffer pools and socket lifecycle _socketManager = new RingSocketManager( ring, maxSockets: MaxConnections, recvBufferSize: RecvBufferSize, - sendBufferSize: SendBufferSize, + sendBufferSize: sendBufferSize, initialBufferSlabs: 8, maxBufferSlabs: 32 ); } + /// + /// Reads the configured send buffer size, coerced to a power of two of at least the platform + /// allocation granularity. IORingBuffer requires this and would otherwise throw at socket + /// creation rather than at startup. + /// + private static int GetSendBufferSize() + { + var configured = ServerConfiguration.GetOrUpdateSetting("network.sendBufferSize", DefaultSendBufferSize); + var size = Math.Max(MinSendBufferSize, configured); + + if (!BitOperations.IsPow2(size)) + { + size = (int)BitOperations.RoundUpToPowerOf2((uint)size); + } + + if (size != configured) + { + logger.Warning( + "network.sendBufferSize {Configured} is not a power of two of at least {Minimum}; using {Adjusted}", + configured, + MinSendBufferSize, + size + ); + } + + return size; + } + /// /// Starts the network server on configured listening addresses. /// diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 6c6262ae4..05d8a8f16 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -444,17 +444,36 @@ public partial class NetState : IComparable, IValueLinkListNode buffer.Length) + { + SendBufferExhausted(span.Length, buffer.Length); + return; } else { @@ -484,6 +503,31 @@ public partial class NetState : IComparable, IValueLinkListNode + /// Handles a packet that cannot be placed in the send buffer. + ///
+ /// + /// 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. + /// + 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 diff --git a/Projects/Server/Network/NetworkCompression.cs b/Projects/Server/Network/NetworkCompression.cs index 1a0154d40..12b4108da 100644 --- a/Projects/Server/Network/NetworkCompression.cs +++ b/Projects/Server/Network/NetworkCompression.cs @@ -73,7 +73,9 @@ public static class NetworkCompression public static int Compress(ReadOnlySpan input, Span output) { - if (input.Length > DefiniteOverflow) + // output.Length < 4 underflows safeOutputLength below (nuint), defeating the hot loop's + // bounds check. Reachable whenever the send buffer is nearly full. + if (input.Length > DefiniteOverflow || output.Length < 4) { return 0; } diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index ff9b3a30b..8953efcba 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,7 +34,7 @@ - + From 967ddf48fa59dfefaf80262f6e201c0f646802e9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:31:37 -0700 Subject: [PATCH 23/64] fix(crowdsec): send a payload LAPI accepts (500 on alerts, 401 on auth) (#2553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Contributing a ban to CrowdSec failed against a real LAPI — `POST /v1/alerts` answered **500**, and depending on the shard's locale, auth answered **401**. Three independent defects, each sufficient on its own. ## Fixes **`scenario_hash` / `scenario_version` were never serialized.** LAPI dereferences both unconditionally when persisting an alert, so omitting them is a nil deref and a 500 rather than a validation error. Both are now emitted with the values a watcher without a hub scenario is expected to send (`""` and `"1.0"`). **`start_at`/`stop_at` were formatted without an `IFormatProvider`.** `:` is the time separator *specifier* in a custom .NET format string, not a literal — a shard running under a culture like `fi-FI` emitted `T15.04.05.123Z`, which Go's `time.RFC3339` rejects, producing another 500. Non-Gregorian cultures (`th-TH`, `ar-SA`) would also shift the year. Formatting is now pinned to `InvariantCulture` in `FormatTimestamp`, which additionally converts non-UTC input — the trailing `Z` is a literal and was previously an unchecked claim. **The `User-Agent` was a plain product string.** LAPI's default watcher profile matches the `crowdsec/` prefix and answers 401 without it, so the header is a protocol constraint, not cosmetic. It is now an `internal const` carrying that reason. Also fixed, same root cause as the timestamp bug: the login-expiry parse used a bare `DateTime.TryParse` on LAPI's RFC3339 `expire`. Under a mismatched culture that silently fails and falls back to a fabricated `UtcNow + 1h`, pushing re-auth past the real expiry and costing a 401-relogin round trip on every send. `capacity` now defaults to `1` instead of `0`, matching the one-decision-per-alert shape actually being sent. ## Note on scope The two 500 causes are independent. On an `en-US` shard only the missing scenario fields were biting; the date bug was latent and would have surfaced as an unexplained regression the first time someone ran a shard under a European locale. ## Verification The emitted payload is field-for-field identical to a hand-verified request that a live LAPI accepts: ```json [ { "scenario": "modernuo/rate-limit", "scenario_hash": "", "scenario_version": "1.0", "message": "ModernUO rate-limit ban for 192.0.2.123", "events_count": 1, "start_at": "2026-07-27T15:04:05.123Z", "stop_at": "2026-07-27T15:04:05.123Z", "capacity": 1, "leakspeed": "0s", "simulated": false, "events": [], "remediation": true, "source": { "scope": "Ip", "value": "192.0.2.123" }, "decisions": [ { "origin": "modernuo", "type": "ban", "scope": "Ip", "value": "192.0.2.123", "duration": "300s", "scenario": "modernuo/rate-limit" } ] } ] ``` Regression tests assert the required scenario fields on the **serialized JSON** rather than the DTO — the DTO is not what goes on the wire — and cover the timestamp as a `[Theory]` across `fi-FI`/`th-TH`/`ar-SA`. `dotnet test --filter "FullyQualifiedName~CrowdSec"` → **21/21 passed**, build clean with 0 warnings. --- .../Network/Bans/CrowdSecAlertClientTests.cs | 11 ++++ .../Network/Bans/CrowdSecReporterTests.cs | 56 +++++++++++++++++++ .../UOContent/Misc/CrowdSec/CrowdSecAlert.cs | 15 ++++- .../Misc/CrowdSec/CrowdSecAlertClient.cs | 19 ++++++- .../Misc/CrowdSec/CrowdSecReporter.cs | 14 ++++- 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs index f8a990f25..756e19b1d 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * *************************************************************************/ +using System; using System.Net; using Server.Network.Bans.CrowdSec; using Xunit; @@ -21,6 +22,16 @@ namespace Server.Tests.Network.Bans; public class CrowdSecAlertClientTests { + /// + /// LAPI's default watcher profile matches the crowdsec/ prefix and answers 401 without it, so + /// this is a protocol constraint rather than a cosmetic product string. + /// + [Fact] + public void UserAgent_IsPrefixedForTheWatcherProfile() + { + Assert.StartsWith("crowdsec/", CrowdSecAlertClient.UserAgent, StringComparison.Ordinal); + } + [Fact] public void BuildDeleteQuery_EscapesOrigin() { diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs index a8daf3c44..6ad850f34 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -15,7 +15,9 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Net; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Server.Network.Bans.CrowdSec; @@ -81,6 +83,60 @@ public class CrowdSecReporterTests Assert.Equal("modernuo/blocklist", decision.Scenario); } + /// + /// LAPI dereferences scenario_hash/scenario_version unconditionally when persisting an alert, so an + /// omitted field is a 500, not a validation error. Asserted on the serialized payload rather than the + /// DTO because that is what actually goes on the wire. + /// + [Fact] + public void BuildAlerts_SerializedPayload_CarriesRequiredScenarioFields() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("9.9.9.9"), TimeSpan.FromHours(1), "rate-limit", false)], + Settings(), + DateTime.UnixEpoch); + + var payload = JsonSerializer.SerializeToNode(alerts)!.AsArray()[0]!.AsObject(); + + Assert.True(payload.ContainsKey("scenario_hash")); + Assert.True(payload.ContainsKey("scenario_version")); + Assert.Equal(JsonValueKind.String, payload["scenario_hash"]!.GetValue().ValueKind); + Assert.Equal(JsonValueKind.String, payload["scenario_version"]!.GetValue().ValueKind); + Assert.Equal(1, payload["capacity"]!.GetValue()); + } + + /// + /// ':' is the time-separator specifier in a custom .NET format string, so a shard under fi-FI used to + /// emit "T00.00.00.000Z" — which Go's time.RFC3339 rejects, and LAPI answers 500 for. th-TH additionally + /// shifts the year via the Buddhist calendar. + /// + [Theory] + [InlineData("fi-FI")] + [InlineData("th-TH")] + [InlineData("ar-SA")] + public void FormatTimestamp_IsIso8601_RegardlessOfCulture(string culture) + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(DateTime.UnixEpoch)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + /// A non-UTC input must still be stamped as UTC — the trailing 'Z' is a literal, not a claim. + [Fact] + public void FormatTimestamp_ConvertsNonUtcInput() + { + var local = new DateTimeOffset(1970, 1, 1, 2, 0, 0, TimeSpan.FromHours(2)).LocalDateTime; + + Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(local)); + } + [Fact] public void FormatDuration_UsesSeconds_FloorsAtOne() { diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs index db0d8d80f..773cf50ec 100644 --- a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs @@ -21,11 +21,24 @@ namespace Server.Network.Bans.CrowdSec; public sealed class CrowdSecAlert { [JsonPropertyName("scenario")] public string Scenario { get; set; } + + /// + /// Required by LAPI. It dereferences the field unconditionally when persisting the alert, so + /// omitting it answers 500 rather than a validation error. Empty is the accepted value for a + /// watcher that isn't shipping a hub scenario. + /// + [JsonPropertyName("scenario_hash")] public string ScenarioHash { get; set; } = ""; + + /// Required alongside ; same 500-on-missing behavior. + [JsonPropertyName("scenario_version")] public string ScenarioVersion { get; set; } = "1.0"; + [JsonPropertyName("message")] public string Message { get; set; } [JsonPropertyName("events_count")] public int EventsCount { get; set; } = 1; [JsonPropertyName("start_at")] public string StartAt { get; set; } [JsonPropertyName("stop_at")] public string StopAt { get; set; } - [JsonPropertyName("capacity")] public int Capacity { get; set; } + + /// Bucket capacity. One decision per alert, so 1 — a leaky-bucket capacity of 0 is nonsense. + [JsonPropertyName("capacity")] public int Capacity { get; set; } = 1; [JsonPropertyName("leakspeed")] public string LeakSpeed { get; set; } = "0s"; [JsonPropertyName("simulated")] public bool Simulated { get; set; } [JsonPropertyName("events")] public object[] Events { get; set; } = []; diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs index af853244a..b91d0ebe5 100644 --- a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -40,6 +41,12 @@ public sealed class CrowdSecAlertClient : ICrowdSecAlertClient { private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + /// + /// The crowdsec/ prefix is load-bearing: LAPI's default watcher profile matches on it and + /// answers 401 for anything else, so this cannot be a plain product string. + /// + internal const string UserAgent = "crowdsec/ModernUO-watcher-1.0"; + private readonly HttpClient _http; private readonly string _machineId; private readonly string _password; @@ -51,7 +58,7 @@ public sealed class CrowdSecAlertClient : ICrowdSecAlertClient { var baseUri = new Uri(settings.LapiUrl, UriKind.Absolute); // fails loud on malformed url _http = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromSeconds(30) }; - _http.DefaultRequestHeaders.Add("User-Agent", "ModernUO-watcher/1.0"); + _http.DefaultRequestHeaders.Add("User-Agent", UserAgent); _machineId = settings.MachineId; _password = settings.Password; } @@ -71,7 +78,15 @@ public sealed class CrowdSecAlertClient : ICrowdSecAlertClient var login = await response.Content.ReadFromJsonAsync(_jsonOptions, token) .ConfigureAwait(false); _token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token."); - _tokenExpiresUtc = DateTime.TryParse(login.Expire, out var exp) ? exp.ToUniversalTime() : DateTime.UtcNow.AddHours(1); + + // LAPI returns an RFC3339 expiry. Parse it invariantly for the same reason we format invariantly: + // the current culture must not decide whether a machine-readable timestamp is understood. + _tokenExpiresUtc = DateTime.TryParse( + login.Expire, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal, + out var exp + ) ? exp : DateTime.UtcNow.AddHours(1); } private void Authorize(HttpRequestMessage message) => diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs index 306d4ab53..e38aa17a6 100644 --- a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs +++ b/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Net; using System.Threading; using System.Threading.Channels; @@ -357,7 +358,7 @@ public sealed class CrowdSecReporter : IBanReporter byIp[item.Ip.ToString()] = item; } - var timestamp = nowUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var timestamp = FormatTimestamp(nowUtc); var alerts = new List(byIp.Count); foreach (var (value, item) in byIp) @@ -390,6 +391,17 @@ public sealed class CrowdSecReporter : IBanReporter return alerts; } + /// + /// ISO8601/RFC3339 UTC timestamp for start_at/stop_at. LAPI parses these with Go's + /// time.RFC3339 and answers 500 when the parse fails, so the format must be culture-independent: + /// ':' is the *time separator* specifier in a custom .NET format string, and a shard running under a + /// culture like fi-FI would otherwise emit "T12.34.56.789Z". InvariantCulture also pins the Gregorian + /// calendar, which non-Gregorian cultures (th-TH, ar-SA) would otherwise shift the year for. + /// + internal static string FormatTimestamp(DateTime time) => + (time.Kind == DateTimeKind.Utc ? time : time.ToUniversalTime()) + .ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + /// CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient. internal static string FormatDuration(TimeSpan ttl) { From b8d3fec59a61e5cfe4e8a8f25b54117276b5175b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:29:03 -0700 Subject: [PATCH 24/64] fix(opl): refuse property list invalidation raised from inside GetProperties (#2555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug Any property getter reached from `GetProperties` that calls `InvalidateProperties` takes the tooltip build down with it: ``` System.ArgumentNullException: Value cannot be null. (Parameter 'array') at Server.ObjectPropertyList.AppendStringDirect(String value) at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list) ``` `InvalidateProperties` rebuilds **in place** — `Reset()`, then `GetProperties()` again on the same instance — and `Reset()` does two destructive things to a build already in flight: 1. **It returns the pooled interpolation buffer.** The compiler rents it in the handler ctor and returns it in the closing `Add`, so *every hole is evaluated while it is live*: ```csharp var handler = new InterpolatedStringHandler(1, 2, list); // InitializeInterpolation() RENTS handler.AppendFormatted(pl.Rank.Title); // <-- getter runs HERE handler.AppendLiteral("\t"); handler.AppendFormatted(faction.Definition.PropName); list.Add(1060776, ref handler); // consumes span, RETURNS ``` ``` GetProperties(list) ├─ InitializeInterpolation() -> _arrayToReturnToPool = Rent(256) buffer LIVE ├─ « hole 1: pl.Rank.Title » │ └─ PlayerState.Rank.get (lazy recompute) │ └─ Invalidate() -> InvalidateProperties() -> m_PropertyList.Reset() │ └─ Dispose(): Return(buf); _arrayToReturnToPool = null buffer GONE └─ handler.AppendFormatted("Knight") └─ _arrayToReturnToPool.AsSpan(_pos..) └─ ArgumentNullException (Parameter 'array') ``` It surfaces as `ArgumentNullException` rather than `NullReferenceException` because the `Range` overload of `AsSpan` must read `array.Length`, so the BCL null-checks and names the parameter `array`. 2. **It rewinds the packet cursor**, so properties already written are overwritten by the nested pass — a silently corrupted tooltip even where the buffer survives. ## The fix: refuse, don't recover There is no correct recovery, and retrying the build would only hide the defect. A nested invalidation now logs an error with a stack trace, **throws in `DEBUG`** so it gets found and fixed, and in `RELEASE` returns without touching the list — a possibly stale tooltip, but no crash, no corrupted packet, and nothing leaked back to the pool. Getters that genuinely must invalidate should defer: ```csharp Timer.DelayCall(InvalidateProperties); ``` The guard flag lives on the `ObjectPropertyList`, not the entity: it is that list's own lifecycle, it costs nothing (both `Item` and `ObjectPropertyList` absorb it in existing padding, and the list is allocated lazily), and it stays correct when builds for different entities nest. Base instance sizes are unchanged from `main`: Item 128 B, Mobile 792 B, ObjectPropertyList 72 B, PlayerMobile 1216 B. `PropertyList` also publishes the list into `m_PropertyList` **before** building it rather than assigning through `??=` afterwards, so a nested `InvalidateProperties` sees the build in progress instead of recursing into a second throwaway list whose work is discarded. `ObjectPropertyList` re-rents its scratch buffer instead of spanning a null array, so a stray `Reset()` from any other caller degrades rather than aborting `GetProperties`. ## Factions `PlayerState`: maintained, not lazily computed The getter that surfaced this is now a plain field read — the whole `if (m_InvalidateRank)` block and the flag itself are gone: ```csharp public RankDefinition Rank => m_Rank; ``` `UpdateRank()` recomputes at each point an input actually changes: | Site | Why | |---|---| | `RankIndex` setter | this player's index changed | | end of `KillPoints` setter | two paths write `m_RankIndex` directly, bypassing the setter; runs once the swap bookkeeping and `ZeroRankOffset` have settled | | `Faction.AddMember` | *after* the insert — the member count is not settled during the ctor | | `FactionState` load | once ordering and `ZeroRankOffset` are final | Supporting fixes this forced out: - **Both ctors seed the lowest rank.** Nothing recomputes on read any more, so `Rank` has to be usable immediately — including for members that never get a `RankIndex` assigned, which is *every member with no kill points*. Without this, `Rank.Title` NREs. - **`Rank` always resolves.** Ranks are ordered by `Required` descending ending at `0`, so a *negative* percent (`RankIndex` out of sync with `ZeroRankOffset`) matched nothing and left `m_Rank` null. It no longer divides by a zero `ZeroRankOffset` either. - **A pre-existing staleness bug.** The `KillPoints` setter writes `m_RankIndex` directly in two places, so the cached rank was never refreshed when a player crossed zero kill points. All six readers of `Rank` were checked; none relied on the old side effect. One behaviour change worth flagging: rank refreshes are now **eager** where they used to be lazy, so a `KillPoints` change invalidates each swapped player as it happens. The swap loops break as soon as ordering is satisfied — typically 0–2 swaps — but it is on the path that runs on every faction kill. ## Documentation The rule is written down so it is enforceable rather than folklore: - **CLAUDE.md** audit rule 19 - **`dev-docs/property-lists.md`** — new "Never Invalidate From Inside `GetProperties`" section with the failing/passing pattern - **`dev-docs/claude-skills/modernuo-property-lists.md`** — key rule + anti-pattern - **`dev-docs/claude-skills/modernuo-code-audit.md`** — rule 19, ERROR severity ## Tests - `ObjectPropertyListReentrancyTests` — `Reset()` and `Dispose()` re-entered mid-hole (both red against `main` with the exact exception above), nesting behaviour, and the new contract: `DEBUG` throws, `RELEASE` survives, and the build is never retried into a loop. - `FactionRankTests` — `Rank` is populated before anything reads it, tracks `RankIndex` without a read, is stable across reads, and still resolves when `RankIndex` is out of sync with `ZeroRankOffset`. Red-verified: removing the ctor seed fails the first one. 793/793 `Server.Tests` and 608/608 `UOContent.Tests` pass. ## Noted, not addressed here `~ObjectPropertyList()` returns the rented array to `STArrayPool.Shared` from the **finalizer thread**, and that pool is single-threaded by design. Left alone as a separate concern. --- CLAUDE.md | 1 + .../ObjectPropertyListReentrancyTests.cs | 142 ++++++++++++++++++ Projects/Server/Items/Item.cs | 54 ++++++- Projects/Server/Mobiles/Mobile.cs | 52 ++++++- .../Server/PropertyList/ObjectPropertyList.cs | 31 ++++ .../Engines/Factions/FactionRankTests.cs | 100 ++++++++++++ .../Engines/Factions/Core/Faction.cs | 6 +- .../Engines/Factions/Core/FactionState.cs | 7 + .../Engines/Factions/Core/PlayerState.cs | 104 ++++++++----- dev-docs/claude-skills/modernuo-code-audit.md | 11 +- .../claude-skills/modernuo-property-lists.md | 7 + dev-docs/property-lists.md | 60 ++++++++ 12 files changed, 530 insertions(+), 45 deletions(-) create mode 100644 Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 1b52b6816..0c41d2087 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 16. **Prefer switch expressions and switch-when** — use switch expressions for value mapping and switch-when for pattern matching where they improve readability. Exception: skip if unreadable or cold path → `dev-docs/code-standards.md` 17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md` 18. **Interpolation anti-patterns on handler-aware APIs** — `Send*`/`Say`/`Emote`/`PublicOverhead*`/`IPropertyList.Add`/gump `AddLabel`/`AddHtml`/`Html.Center`/`SpanWriter.Write*` all have `ref RawInterpolatedStringHandler` overloads that allocate zero strings, but only when the call-site argument is a `$"..."` literal directly. Avoid: ternaries with interpolated branches (`Send(c ? $"a" : $"b")`), switch expressions with interpolated arms, pre-built `var s = $"..."` locals (single-use), `.ToString()` / `.String()` / `string.Format` inside holes, string concat (`{a + b}`), LINQ string ops in holes. Use `:L` format spec for lowercase (`{rank:L}` not `rank.ToString().ToLowerInvariant()`) → `dev-docs/string-handling.md` § Interpolation Anti-Patterns +19. **No `InvalidateProperties()` from inside `GetProperties`** — every property a `GetProperties` override reads must be a pure read. `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild), and `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. A getter that invalidates therefore throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from an unrelated-looking line, or silently corrupts the tooltip. The engine refuses and logs an error; `DEBUG` throws. Lazy recomputation in a getter is fine — the *notification* is not. Invalidate in the setter that changes the value, or defer with `Timer.DelayCall(InvalidateProperties)` → `dev-docs/property-lists.md` § Never Invalidate From Inside `GetProperties` ## Dev-Docs Reference diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs new file mode 100644 index 000000000..8da5f748a --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs @@ -0,0 +1,142 @@ +using System; +using Xunit; + +namespace Server.Tests; + +/// +/// The interpolation buffer is rented by the handler ctor and returned by the closing Add, so every +/// hole is evaluated while it is live. A Reset()/Dispose() landing in that window used to leave the +/// next Append* spanning a null array: ArgumentNullException, parameter "array". +/// +public class ObjectPropertyListReentrancyTests +{ + // Stands in for a property getter that invalidates while its own tooltip is being built. + private static string ResettingHole(ObjectPropertyList list, string value) + { + list.Reset(); + return value; + } + + private static string DisposingHole(ObjectPropertyList list, string value) + { + list.Dispose(); + return value; + } + + [Fact] + public void InterpolatedAdd_ResetMidHole_DoesNotThrow() + { + var opl = new ObjectPropertyList(null); + + var ex = Record.Exception( + () => opl.Add(1060776, $"{ResettingHole(opl, "Knight")}\t{"Council of Mages"}") + ); + + Assert.Null(ex); + } + + [Fact] + public void InterpolatedAdd_DisposeMidHole_DoesNotThrow() + { + var opl = new ObjectPropertyList(null); + + var ex = Record.Exception( + () => opl.Add(1060776, $"{DisposingHole(opl, "Knight")}\t{"Council of Mages"}") + ); + + Assert.Null(ex); + } +} + +/// +/// The guard is per-list, so nested builds (a GetProperties override that reads another entity's +/// PropertyList) cannot unguard the outer one the way a single shared slot would. +/// +public class ObjectPropertyListNestedBuildTests +{ + [Fact] + public void NestedBuild_DoesNotUnguardTheOuterList() + { + var outer = new ObjectPropertyList(null); + var inner = new ObjectPropertyList(null); + + outer.IsBuilding = true; + inner.IsBuilding = true; // another entity starts building, and finishes + inner.IsBuilding = false; + + Assert.True(outer.IsBuilding); + } + + [Fact] + public void Reset_MidInterpolation_LeavesTheListUsable() + { + var opl = new ObjectPropertyList(null); + + opl.Add(1060776, $"{Reset(opl, "Knight")}\t{"Council of Mages"}"); + opl.Add(1042971, "still working"); + opl.Terminate(); + + Assert.NotNull(opl.Buffer); + } + + private static string Reset(ObjectPropertyList list, string value) + { + list.Reset(); + return value; + } +} + + +/// +/// Invalidating from inside GetProperties is a defect in the getter, not a case to recover from: +/// DEBUG throws, RELEASE keeps a possibly stale tooltip without crashing or leaking. +/// +[Collection("Sequential Server Tests")] +public class PropertyListInvalidationDuringBuildTests +{ + private class SelfInvalidatingMobile : Mobile + { + public int Builds; + + public override void GetProperties(IPropertyList list) + { + Builds++; + base.GetProperties(list); + InvalidateProperties(); + list.Add(1060776, $"{"Knight"}\t{"Council of Mages"}"); + } + } + + private static SelfInvalidatingMobile Place(int x) + { + var m = new SelfInvalidatingMobile(); + m.MoveToWorld(new Point3D(x, 1000, 0), Map.Felucca); + return m; + } + + [Fact] + public void InvalidatingFromGetProperties_FailsLoudlyWithoutTearingDownTheBuild() + { + var wasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + + try + { + var m = Place(1000); + +#if DEBUG + Assert.Throws(() => _ = m.PropertyList); +#else + Assert.Null(Record.Exception(() => _ = m.PropertyList)); +#endif + + // Refused, not retried. + Assert.Equal(1, m.Builds); + m.Delete(); + } + finally + { + ObjectPropertyList.Enabled = wasEnabled; + } + } +} diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 4d5ee13b3..186437400 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -14,6 +14,7 @@ *************************************************************************/ using System; +using System.Diagnostics; using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; @@ -798,7 +799,22 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual int HuedItemID => m_ItemID; - public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); + public ObjectPropertyList PropertyList + { + get + { + if (m_PropertyList == null) + { + // Publish the list before building it so a nested InvalidateProperties can see the + // build in progress and defer instead of recursing into a second throwaway list. + var list = new ObjectPropertyList(this); + m_PropertyList = list; + InitializePropertyList(list); + } + + return m_PropertyList; + } + } /// /// Overridable. Fills an with everything applicable. By default, this invokes @@ -2429,9 +2445,19 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) { - GetProperties(list); - AppendChildProperties(list); - list.Terminate(); + list.IsBuilding = true; + + try + { + GetProperties(list); + AppendChildProperties(list); + list.Terminate(); + } + finally + { + list.IsBuilding = false; + } + return list; } @@ -2448,6 +2474,26 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert return; } + // Always a bug in the property getter, and there is no correct recovery: refuse rather than + // hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws. + // See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties". + if (m_PropertyList?.IsBuilding == true) + { + logger.Error( + "{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}", + this, + new StackTrace() + ); + +#if DEBUG + throw new InvalidOperationException( + $"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter." + ); +#else + return; +#endif + } + if (m_Map != null && m_Map != Map.Internal && !World.Loading) { int? oldHash; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 34daaf796..f22c9a939 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -26,6 +26,7 @@ using Server.Network; using Server.Prompts; using Server.Targeting; using System; +using System.Diagnostics; using System.Collections.Generic; using System.Runtime.CompilerServices; using Server.Buffers; @@ -2291,7 +2292,22 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial); public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106; - public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); + public ObjectPropertyList PropertyList + { + get + { + if (m_PropertyList == null) + { + // Publish the list before building it so a nested InvalidateProperties can see the + // build in progress and defer instead of recursing into a second throwaway list. + var list = new ObjectPropertyList(this); + m_PropertyList = list; + InitializePropertyList(list); + } + + return m_PropertyList; + } + } public virtual void GetProperties(IPropertyList list) { @@ -7225,8 +7241,18 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) { - GetProperties(list); - list.Terminate(); + list.IsBuilding = true; + + try + { + GetProperties(list); + list.Terminate(); + } + finally + { + list.IsBuilding = false; + } + return list; } @@ -7243,6 +7269,26 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro return; } + // Always a bug in the property getter, and there is no correct recovery: refuse rather than + // hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws. + // See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties". + if (m_PropertyList?.IsBuilding == true) + { + logger.Error( + "{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}", + this, + new StackTrace() + ); + +#if DEBUG + throw new InvalidOperationException( + $"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter." + ); +#else + return; +#endif + } + if (m_Map != null && m_Map != Map.Internal && !World.Loading) { int? oldHash; diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 6404eccbd..58b247990 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -55,6 +55,12 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable private int _pos; private char[]? _arrayToReturnToPool; + /// + /// True while GetProperties is populating this list. Set by the owning entity so a nested + /// InvalidateProperties can be refused instead of Reset()ing a build already in flight. + /// + internal bool IsBuilding { get; set; } + public ObjectPropertyList(IEntity? e) { Entity = e; @@ -319,8 +325,23 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable private static int GetDefaultLength(int literalLength, int formattedCount) => Math.Max(256, literalLength + formattedCount * 11); + // Reset()/Dispose() return the scratch buffer to the pool. If either lands while a `$"..."` + // handler is still appending, re-rent rather than spanning a null array and throwing out of + // GetProperties. Mobile/Item hold the primary guard; this covers any other caller. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureInterpolationBuffer() + { + if (_arrayToReturnToPool == null) + { + _arrayToReturnToPool = STArrayPool.Shared.Rent(256); + _pos = 0; + } + } + public void AppendLiteral(string value) { + EnsureInterpolationBuffer(); + if (value.Length == 1) { var chars = _arrayToReturnToPool.AsSpan(); @@ -354,6 +375,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value) { + EnsureInterpolationBuffer(); + string? s; if (value is IFormattable) { @@ -384,6 +407,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value, string? format) { + EnsureInterpolationBuffer(); + // '#' marks an integer argument as a cliloc ("#"). Integers only -- a float/double/decimal // '#' is the standard numeric format, not a cliloc marker. if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte) @@ -442,6 +467,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(ReadOnlySpan value) { + EnsureInterpolationBuffer(); + if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..))) { _pos += value.Length; @@ -454,6 +481,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) { + EnsureInterpolationBuffer(); + var leftAlign = false; if (alignment < 0) { @@ -488,6 +517,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(string? value) { + EnsureInterpolationBuffer(); + if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true) { _pos += value.Length; diff --git a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs new file mode 100644 index 000000000..2416055b1 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using Server; +using Server.Factions; +using Xunit; + +namespace UOContent.Tests; + +/// +/// PlayerState.Rank is read from GetProperties, so it must stay a plain field read. These pin what +/// that requires: the rank is never null, and it is correct without anyone having read it first. +/// +[Collection("Sequential UOContent Tests")] +public class FactionRankTests +{ + // The faction ctor builds its own Definition, so no world state is needed. + private static Faction NewFaction() => new CouncilOfMages(); + + private static PlayerState AddMember(Faction faction, List owner) + { + var state = new PlayerState(new Mobile(), faction, owner); + owner.Add(state); + return state; + } + + [Fact] + public void Rank_IsPopulatedBeforeAnythingReadsIt() + { + var faction = NewFaction(); + var state = new PlayerState(new Mobile(), faction, []); + + // Nothing recomputes on read, so the ctor must leave a usable value or Rank.Title NREs. + Assert.NotNull(state.Rank); + Assert.NotNull(state.Rank.Title); + } + + [Fact] + public void Rank_IsTheLowestRank_ForAnUnrankedMember() + { + var faction = NewFaction(); + var owner = new List(); + var a = AddMember(faction, owner); + var b = AddMember(faction, owner); + + a.UpdateRank(); + b.UpdateRank(); + + var lowest = faction.Definition.Ranks[^1]; + + Assert.Equal(lowest.Rank, a.Rank.Rank); + Assert.Equal(lowest.Rank, b.Rank.Rank); + } + + [Fact] + public void SettingRankIndex_UpdatesRankWithoutAnyoneReadingIt() + { + var faction = NewFaction(); + var owner = new List(); + var top = AddMember(faction, owner); + var bottom = AddMember(faction, owner); + + faction.ZeroRankOffset = 2; + + top.RankIndex = 0; + bottom.RankIndex = 1; + + // No read triggered these, yet the ordering is reflected. + Assert.True(top.Rank.Rank > bottom.Rank.Rank); + } + + [Fact] + public void ReadingRank_IsStableAndSideEffectFree() + { + var faction = NewFaction(); + var owner = new List(); + var state = AddMember(faction, owner); + + faction.ZeroRankOffset = 1; + state.RankIndex = 0; + + var first = state.Rank; + var second = state.Rank; + + Assert.Same(first, second); + } + + [Fact] + public void RankIndexOutOfSyncWithZeroRankOffset_StillResolvesARank() + { + var faction = NewFaction(); + var owner = new List(); + var a = AddMember(faction, owner); + AddMember(faction, owner); + + // A negative percent used to match no rank at all, leaving Rank null. + faction.ZeroRankOffset = 1; + a.RankIndex = 5; + + Assert.NotNull(a.Rank); + } +} diff --git a/Projects/UOContent/Engines/Factions/Core/Faction.cs b/Projects/UOContent/Engines/Factions/Core/Faction.cs index 33166f5a0..6c440584b 100644 --- a/Projects/UOContent/Engines/Factions/Core/Faction.cs +++ b/Projects/UOContent/Engines/Factions/Core/Faction.cs @@ -242,7 +242,11 @@ public abstract class Faction : IComparable, ISpanParsable public virtual void AddMember(Mobile mob) { - Members.Insert(ZeroRankOffset, new PlayerState(mob, this, Members)); + var state = new PlayerState(mob, this, Members); + Members.Insert(ZeroRankOffset, state); + + // Ranked after the insert: the ctor ran while Owner was still short a member. + state.UpdateRank(); mob.AddToBackpack(FactionItem.Imbue(new Robe(), this, false, Definition.HuePrimary)); mob.SendLocalizedMessage(1010374); // You have been granted a robe which signifies your faction diff --git a/Projects/UOContent/Engines/Factions/Core/FactionState.cs b/Projects/UOContent/Engines/Factions/Core/FactionState.cs index 0170c718c..b8eba5091 100644 --- a/Projects/UOContent/Engines/Factions/Core/FactionState.cs +++ b/Projects/UOContent/Engines/Factions/Core/FactionState.cs @@ -114,6 +114,13 @@ public class FactionState } } + // The loop above only assigns RankIndex to members with kill points, and nothing + // computes rank on read, so rank everyone now that the ordering has settled. + foreach (var player in Members) + { + player.UpdateRank(); + } + FactionItems = []; if (version >= 2) diff --git a/Projects/UOContent/Engines/Factions/Core/PlayerState.cs b/Projects/UOContent/Engines/Factions/Core/PlayerState.cs index 792d2b4e3..e420836ce 100644 --- a/Projects/UOContent/Engines/Factions/Core/PlayerState.cs +++ b/Projects/UOContent/Engines/Factions/Core/PlayerState.cs @@ -8,7 +8,6 @@ public class PlayerState : IComparable { private Town m_Finance; - private bool m_InvalidateRank = true; private int m_KillPoints; private MerchantTitle m_MerchantTitle; private RankDefinition m_Rank; @@ -22,6 +21,10 @@ public class PlayerState : IComparable Faction = faction; Owner = owner; + // Owner does not contain this state yet, so the count is short by one; the caller ranks it + // after inserting. + SeedLowestRank(); + Attach(); Invalidate(); } @@ -54,6 +57,9 @@ public class PlayerState : IComparable } } + // Members are still being read; FactionState ranks everyone once the ordering settles. + SeedLowestRank(); + Attach(); } @@ -116,6 +122,8 @@ public class PlayerState : IComparable Owner.Remove(this); Owner.Insert(Faction.ZeroRankOffset, this); + // Direct, not through RankIndex: ZeroRankOffset is mid-update. The + // UpdateRank() at the end of this setter covers it. m_RankIndex = Faction.ZeroRankOffset; Faction.ZeroRankOffset++; } @@ -180,6 +188,7 @@ public class PlayerState : IComparable } m_KillPoints = value; + UpdateRank(); Invalidate(); } } @@ -193,49 +202,72 @@ public class PlayerState : IComparable if (m_RankIndex != value) { m_RankIndex = value; - m_InvalidateRank = true; + + UpdateRank(); + Invalidate(); } } } - public RankDefinition Rank + /// + /// Read from PlayerMobile.GetProperties, so it must stay a plain field read -- recomputing or + /// invalidating here re-enters the property list build. Maintained by . + /// + public RankDefinition Rank => m_Rank; + + // Lowest rank (Required 0): correct for an unranked member, and never null, so Rank.Title + // cannot NRE before the first UpdateRank(). + private void SeedLowestRank() { - get + var ranks = Faction.Definition.Ranks; + + if (ranks.Length > 0) { - if (m_InvalidateRank) + m_Rank = ranks[^1]; + } + } + + /// + /// Recomputes the cached rank. Call whenever , the faction's + /// ZeroRankOffset, or the member count changes -- and only once they have settled. + /// + public void UpdateRank() + { + var ranks = Faction.Definition.Ranks; + + if (ranks.Length == 0) + { + return; + } + + int percent; + + if (Owner.Count == 1) + { + percent = 1000; + } + else if (m_RankIndex == -1 || Faction.ZeroRankOffset <= 0) + { + percent = 0; + } + else + { + percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset; + } + + // Ranks run Required-descending ending at 0, so anything >= 0 matches below. A negative + // percent (RankIndex out of sync with ZeroRankOffset) would otherwise leave it null. + m_Rank = ranks[^1]; + + for (var i = 0; i < ranks.Length; i++) + { + var check = ranks[i]; + + if (percent >= check.Required) { - var ranks = Faction.Definition.Ranks; - int percent; - - if (Owner.Count == 1) - { - percent = 1000; - } - else if (m_RankIndex == -1) - { - percent = 0; - } - else - { - percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset; - } - - for (var i = 0; i < ranks.Length; i++) - { - var check = ranks[i]; - - if (percent >= check.Required) - { - m_Rank = check; - m_InvalidateRank = false; - break; - } - } - - Invalidate(); + m_Rank = check; + break; } - - return m_Rank; } } diff --git a/dev-docs/claude-skills/modernuo-code-audit.md b/dev-docs/claude-skills/modernuo-code-audit.md index 39a8d4983..5d5af15bb 100644 --- a/dev-docs/claude-skills/modernuo-code-audit.md +++ b/dev-docs/claude-skills/modernuo-code-audit.md @@ -191,8 +191,17 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold" **See**: `dev-docs/string-handling.md` § "Interpolation Anti-Patterns" for the full reference with detailed before/after examples. +### 19. No InvalidateProperties From Inside GetProperties +**Check**: Any property read by a `GetProperties` override — including through helpers — must be a pure read. Flag getters that call `InvalidateProperties()` (or a wrapper like `Invalidate()`) as a side effect. +**Bad**: a `Rank` getter that lazily recomputes and then calls `Invalidate()`; reading it from `GetProperties` re-enters the build. +**Good**: invalidate in the setter that actually changes the value, or defer with `Timer.DelayCall(InvalidateProperties)`. +**Why**: `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild). `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. Re-entering mid-build throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from a line unrelated to the offending getter, or silently corrupts the tooltip. The engine refuses and logs an error, and `DEBUG` throws, so this shows up as a crash in development. +**Note**: Lazy recomputation inside a getter is fine. It is the notification that must not happen there. + +**See**: `dev-docs/property-lists.md` § "Never Invalidate From Inside `GetProperties`". + ## Severity Levels -- **ERROR**: Rules 3, 9, 10, 13 (will cause bugs, build failures, or client-side leaks) +- **ERROR**: Rules 3, 9, 10, 13, 19 (will cause bugs, build failures, or client-side leaks) - **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14, 15, 17 (performance/convention issues) - **INFO**: Rules 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation), 16 (switch patterns — suggest but don't flag) - **ASK**: Rule 11 (need user input) diff --git a/dev-docs/claude-skills/modernuo-property-lists.md b/dev-docs/claude-skills/modernuo-property-lists.md index 4440c63db..51e47ff35 100644 --- a/dev-docs/claude-skills/modernuo-property-lists.md +++ b/dev-docs/claude-skills/modernuo-property-lists.md @@ -20,6 +20,12 @@ description: > 3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax 4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change 5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip +6. **Never invalidate from inside `GetProperties`** -- every property a `GetProperties` override + reads must be a pure read. `InvalidateProperties()` rebuilds in place (`Reset()` + rebuild), so a + getter with that side effect tears down the list mid-build: it returns the pooled interpolation + buffer under an in-flight `$"..."` handler (`ArgumentNullException`, parameter `"array"`) and + rewinds the packet cursor. The engine refuses and logs an error; `DEBUG` throws. Defer instead: + `Timer.DelayCall(InvalidateProperties)` ## IPropertyList Interface @@ -235,6 +241,7 @@ block.Add("Cannot be repaired".AsSpan()); // plain span, no string alloc - **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops - **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed - **One giant `Add()` for multi-line text**: A property over ~512 chars crashes the legacy 2D client. Use `AddChunked`/`OplTextBlock` for variable-length free text +- **Side-effecting property getters**: A getter reached from `GetProperties` that calls `InvalidateProperties()` (directly or via a helper like `Invalidate()`) re-enters the build and is refused — error logged, `DEBUG` throws. Lazy recomputation in a getter is fine; the *notification* is not. Invalidate where the value changes, or `Timer.DelayCall(InvalidateProperties)` ## Real Examples - Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties) diff --git a/dev-docs/property-lists.md b/dev-docs/property-lists.md index 98e26efc1..929429bcd 100644 --- a/dev-docs/property-lists.md +++ b/dev-docs/property-lists.md @@ -301,6 +301,66 @@ public void UseCharge() } ``` +### Never Invalidate From Inside `GetProperties` (CRITICAL) + +`InvalidateProperties()` rebuilds the list **in place** — `Reset()`, then `GetProperties()` again on +the same instance. Calling it from a property getter that the build itself reaches is therefore +re-entrant, and `Reset()` does two destructive things to the build in flight: + +1. It returns the pooled interpolation scratch buffer. The compiler rents that buffer in the + interpolated-string handler's constructor and returns it in the closing `Add`, so **every hole is + evaluated while the buffer is live**. Pulling it out mid-append makes the next `Append*` span a + null array — `ArgumentNullException: Value cannot be null. (Parameter 'array')` thrown out of + `GetProperties`, from a line that looks unrelated to the getter that caused it. +2. It rewinds the packet cursor, so properties already written are overwritten by the nested pass. + +This is always a defect in the property getter, so the engine refuses rather than trying to recover: +a nested call logs an error with a stack trace, throws in `DEBUG`, and in `RELEASE` returns without +touching the list — leaving a possibly stale tooltip, but never a crash, a corrupted packet, or a +leaked pool buffer. Retrying the build would only hide the bug. + +```csharp +// BAD -- a getter with a side effect. Reading it from GetProperties re-enters the build. +public RankDefinition Rank +{ + get + { + if (_invalidateRank) + { + _rank = Recompute(); + _invalidateRank = false; + Invalidate(); // -> InvalidateProperties() -> Reset() on the list being built + } + + return _rank; + } +} + +// GOOD -- getters stay side-effect free; invalidate where the value actually changes. +public int RankIndex +{ + get => _rankIndex; + set + { + if (_rankIndex != value) + { + _rankIndex = value; + _invalidateRank = true; + Invalidate(); + } + } +} +``` + +Lazy recomputation inside a getter is fine — it is the *notification* that must not happen there. If +something genuinely must invalidate in response to a read, defer it off the build: + +```csharp +Timer.DelayCall(InvalidateProperties); +``` + +**Check when writing a `GetProperties` override**: every property it reads must be a pure read. + ## ObjectPropertyList Internals Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`: From aae173a797ca50a2e760df2e2296bb21b6533dea Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:12:17 -0700 Subject: [PATCH 25/64] feat(network): allowlist false-positive IPs, escalate on behavior (#2556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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-.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. --- .gitignore | 6 + CLAUDE.md | 1 + .../Tests/Network/Bans/BanChannelTests.cs | 23 + .../Tests/Network/ForeignProtocolTests.cs | 116 ++++ .../ObjectPropertyListReentrancyTests.cs | 3 + Projects/Server/Network/Bans/BanChannel.cs | 17 + .../Server/Network/Bans/BanConfiguration.cs | 28 +- Projects/Server/Network/Bans/BanReasons.cs | 48 ++ Projects/Server/Network/ForeignProtocol.cs | 146 +++++ .../Network/NetState/NetState.Network.cs | 19 +- Projects/Server/Network/NetState/NetState.cs | 52 +- .../Network/AutoDenylist/AutoDenylistTests.cs | 139 +++++ .../Tests/Network/BanExemptionsTests.cs | 122 ++++ .../LoginAllowlist/LoginAllowlistTests.cs | 218 +++++++ .../UOContent/Accounting/AccountHandler.cs | 2 + .../Commands/Generic/Commands/Commands.cs | 2 +- Projects/UOContent/Gumps/AdminGump.cs | 6 +- .../Network/AutoDenylist/AutoDenylist.cs | 219 +++++++ .../AutoDenylist/AutoDenylistConfiguration.cs | 81 +++ Projects/UOContent/Network/BanExemptions.cs | 61 ++ .../Blocklist/BlocklistConfiguration.cs | 13 + .../Blocklist/BlocklistFile.cs | 0 .../Blocklist/BlocklistFilter.cs | 17 +- .../Blocklist/BlocklistSnapshot.cs | 6 + .../Network/Blocklist/FileAllowlist.cs | 313 ++++++++++ .../Blocklist/PromotedGuard.cs | 0 .../CrowdSec/CrowdSecAlert.cs | 0 .../CrowdSec/CrowdSecAlertClient.cs | 0 .../CrowdSec/CrowdSecConfiguration.cs | 0 .../CrowdSec/CrowdSecReporter.cs | 2 +- .../Firewall/BaseFirewallEntry.cs | 0 .../Firewall/CidrFirewallEntry.cs | 0 .../{Misc => Network}/Firewall/Firewall.cs | 0 .../Firewall/FirewallConnectionFilter.cs | 0 .../Firewall/FirewallSettings.cs | 0 .../Firewall/IFirewallEntry.cs | 0 .../Firewall/SingleIpFirewallEntry.cs | 0 .../Network/LoginAllowlist/LoginAllowlist.cs | 385 ++++++++++++ .../LoginAllowlistConfiguration.cs | 106 ++++ dev-docs/ip-bans-and-allowlists.md | 216 +++++++ dev-docs/networking-packets.md | 21 +- tools/Export-IpBlocklist.ps1 | 569 +++++++++++++++++- 42 files changed, 2925 insertions(+), 32 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs create mode 100644 Projects/Server/Network/Bans/BanReasons.cs create mode 100644 Projects/Server/Network/ForeignProtocol.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs create mode 100644 Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs create mode 100644 Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs create mode 100644 Projects/UOContent/Network/BanExemptions.cs rename Projects/UOContent/{Misc => Network}/Blocklist/BlocklistConfiguration.cs (85%) rename Projects/UOContent/{Misc => Network}/Blocklist/BlocklistFile.cs (100%) rename Projects/UOContent/{Misc => Network}/Blocklist/BlocklistFilter.cs (92%) rename Projects/UOContent/{Misc => Network}/Blocklist/BlocklistSnapshot.cs (96%) create mode 100644 Projects/UOContent/Network/Blocklist/FileAllowlist.cs rename Projects/UOContent/{Misc => Network}/Blocklist/PromotedGuard.cs (100%) rename Projects/UOContent/{Misc => Network}/CrowdSec/CrowdSecAlert.cs (100%) rename Projects/UOContent/{Misc => Network}/CrowdSec/CrowdSecAlertClient.cs (100%) rename Projects/UOContent/{Misc => Network}/CrowdSec/CrowdSecConfiguration.cs (100%) rename Projects/UOContent/{Misc => Network}/CrowdSec/CrowdSecReporter.cs (99%) rename Projects/UOContent/{Misc => Network}/Firewall/BaseFirewallEntry.cs (100%) rename Projects/UOContent/{Misc => Network}/Firewall/CidrFirewallEntry.cs (100%) rename Projects/UOContent/{Misc => Network}/Firewall/Firewall.cs (100%) rename Projects/UOContent/{Misc => Network}/Firewall/FirewallConnectionFilter.cs (100%) rename Projects/UOContent/{Misc => Network}/Firewall/FirewallSettings.cs (100%) rename Projects/UOContent/{Misc => Network}/Firewall/IFirewallEntry.cs (100%) rename Projects/UOContent/{Misc => Network}/Firewall/SingleIpFirewallEntry.cs (100%) create mode 100644 Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs create mode 100644 Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs create mode 100644 dev-docs/ip-bans-and-allowlists.md diff --git a/.gitignore b/.gitignore index 222cdabf2..2cc93a052 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,18 @@ /Distribution/bsdtar /Distribution/Configuration/antimacro.json /Distribution/Configuration/assistants.json +/Distribution/Configuration/auto-denylist.json /Distribution/Configuration/bans.json /Distribution/Configuration/blocklist.json /Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json +/Distribution/Configuration/ip-allowlist*.txt +/Distribution/Configuration/ip-allowlist*.txt.tmp /Distribution/Configuration/ip-blocklist.txt /Distribution/Configuration/ip-blocklist.txt.tmp +/Distribution/Configuration/login-allowlist.json +/Distribution/Configuration/login-allowlist.txt +/Distribution/Configuration/login-allowlist.txt.tmp /Distribution/Configuration/modernuo.json /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json diff --git a/CLAUDE.md b/CLAUDE.md index 0c41d2087..7f24661a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | +| IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | | Region system | `dev-docs/regions.md` | | String handling & ValueStringBuilder | `dev-docs/string-handling.md` | | RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` | diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs index a48647152..5eb1895d8 100644 --- a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -60,6 +60,29 @@ public class BanChannelTests Assert.Single(good.Reports); // the throwing reporter does not block the others } + [Fact] + public void Report_SuppressedForExemptAddress() + { + var reporter = new FakeReporter(); + BanChannel.ConfigureForTesting([reporter]); + + var exempt = IPAddress.Parse("203.0.113.7"); + BanChannel.IsExempt = (ip, _) => ip.Equals(exempt); + + try + { + BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); + Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted + + BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); + Assert.Single(reporter.Reports); // everyone else is still contributed + } + finally + { + BanChannel.IsExempt = null; + } + } + [Fact] public void Retract_ReachesRetractCapableReporters() { diff --git a/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs b/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs new file mode 100644 index 000000000..95a3c4114 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs @@ -0,0 +1,116 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ForeignProtocolTests.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.Text; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +public class ForeignProtocolTests +{ + private static ForeignProtocolMatch Identify(byte[] bytes, out ForeignProtocolKind kind) => + ForeignProtocol.Identify(bytes, out kind); + + private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s); + + [Theory] + [InlineData("GET / HTTP/1.1\r\n")] + [InlineData("POST /a HTTP/1.1\r\n")] + [InlineData("HEAD / HTTP/1.0\r\n")] + [InlineData("OPTIONS * HTTP/1.1\r\n")] + [InlineData("CONNECT host:443 HTTP/1.1\r\n")] + [InlineData("DELETE /x HTTP/1.1\r\n")] + public void Http_requests_are_identified(string request) + { + Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii(request), out var kind)); + Assert.Equal(ForeignProtocolKind.Http, kind); + } + + [Fact] + public void Ssh_banner_is_identified() + { + Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii("SSH-2.0-OpenSSH_9.6"), out var kind)); + Assert.Equal(ForeignProtocolKind.Ssh, kind); + } + + [Fact] + public void Tls_client_hello_is_identified() + { + // handshake, TLS 1.2 record, length 0x0100, ClientHello + byte[] hello = [0x16, 0x03, 0x03, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFC]; + + Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(hello, out var kind)); + Assert.Equal(ForeignProtocolKind.Tls, kind); + } + + [Fact] + public void Tls_prefix_without_a_client_hello_is_not_foreign() + { + // A seed can spell 0x16 0x03 0x0? -- the address 22.3.x.x. Byte five is a packet id, not a + // handshake type, so it must fall through to normal parsing. + byte[] seedThenLogin = [0x16, 0x03, 0x03, 0x04, 0x00, 0x80, 0x00, 0x00]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out var kind)); + Assert.Equal(ForeignProtocolKind.None, kind); + } + + [Fact] + public void Seed_that_spells_an_http_method_falls_through() + { + // Seed 0x47455420 spells "GET ". The next byte is the 0x80 login packet id, not printable, so this + // is a real client and must not be flagged. + byte[] seedThenLogin = [(byte)'G', (byte)'E', (byte)'T', (byte)' ', 0x80, 0x00, 0x3A, 0x00]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out _)); + } + + [Theory] + [InlineData(0x80)] // login request + [InlineData(0x91)] // game server login + [InlineData(0xEF)] // new-style seed packet + public void Ordinary_uo_openings_are_not_foreign(byte secondPacketId) + { + byte[] buffer = [0x7F, 0x00, 0x00, 0x01, secondPacketId, 0x00, 0x00, 0x00]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _)); + } + + [Fact] + public void Encrypted_login_is_not_mistaken_for_a_foreign_protocol() + { + // A legitimate client with encryption on when the shard expects none. The login cipher is a + // byte-for-byte XOR, so this is noise of exactly the right length. + byte[] buffer = [0x7F, 0x00, 0x00, 0x01, 0xC3, 0x9A, 0x04, 0xE1, 0x55, 0xB2]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _)); + } + + [Fact] + public void Prefix_match_without_enough_bytes_waits() + { + // Framing is never assumed: "GET " split from its request line must wait. + Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET "), out _)); + Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET /"), out _)); + } + + [Fact] + public void Too_few_bytes_to_match_a_prefix_is_not_foreign() + { + // Under four bytes the caller's own short-read handling applies. + Assert.Equal(ForeignProtocolMatch.None, Identify(Ascii("GE"), out _)); + Assert.Equal(ForeignProtocolMatch.None, Identify([], out _)); + } +} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs index 8da5f748a..a30e9c9e3 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs @@ -8,6 +8,8 @@ namespace Server.Tests; /// hole is evaluated while it is live. A Reset()/Dispose() landing in that window used to leave the /// next Append* spanning a null array: ArgumentNullException, parameter "array". /// +// Sequential: building a list rents from STArrayPool, which is not thread-safe. +[Collection("Sequential Server Tests")] public class ObjectPropertyListReentrancyTests { // Stands in for a property getter that invalidates while its own tooltip is being built. @@ -52,6 +54,7 @@ public class ObjectPropertyListReentrancyTests /// The guard is per-list, so nested builds (a GetProperties override that reads another entity's /// PropertyList) cannot unguard the outer one the way a single shared slot would. /// +[Collection("Sequential Server Tests")] public class ObjectPropertyListNestedBuildTests { [Fact] diff --git a/Projects/Server/Network/Bans/BanChannel.cs b/Projects/Server/Network/Bans/BanChannel.cs index 6d4ead9b1..b682158d0 100644 --- a/Projects/Server/Network/Bans/BanChannel.cs +++ b/Projects/Server/Network/Bans/BanChannel.cs @@ -103,8 +103,25 @@ public static class BanChannel } /// Fans a locally-decided ban out to every reporter. Non-blocking; never throws. + /// + /// Optional content-supplied exemption; true drops the contribution before any reporter sees it. This + /// withholds escalation only — the gate that reached the verdict has already acted. + /// + /// + /// An implementation that ignores reason would silently swallow manual bans. See + /// . + /// + public static Func IsExempt { get; set; } + public static void Report(IPAddress ip, TimeSpan ttl, string reason) { + var exempt = IsExempt; + if (exempt != null && exempt(ip, reason)) + { + logger.Debug("{Address} not contributed ('{Reason}'): exempt", ip, reason); + return; + } + var reporters = _reporters; for (var i = 0; i < reporters.Length; i++) { diff --git a/Projects/Server/Network/Bans/BanConfiguration.cs b/Projects/Server/Network/Bans/BanConfiguration.cs index 2a2af40cf..d678a6470 100644 --- a/Projects/Server/Network/Bans/BanConfiguration.cs +++ b/Projects/Server/Network/Bans/BanConfiguration.cs @@ -29,16 +29,24 @@ public static class BanConfiguration { private const string _path = "Configuration/bans.json"; - public static BanSettings Settings { get; private set; } + private static bool _loaded; + + /// + /// Never null: the accept and reap paths read this per connection, including before + /// has run (a harness driving NetState.Slice directly), so it starts at the record's defaults. + /// + public static BanSettings Settings { get; private set; } = new(); public static void Configure() { - // Idempotent: a second call must not re-deserialize or overwrite an operator's edits. - if (Settings != null) + // Idempotent; flagged rather than null-checked because Settings is non-null from the start. + if (_loaded) { return; } + _loaded = true; + var path = Path.Join(Core.BaseDirectory, _path); if (File.Exists(path)) @@ -73,4 +81,18 @@ public record BanSettings /// Duration reported for an auto-detected (rate-limit) ban. [JsonPropertyName("autoBanDuration")] public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4); + + /// + /// Whether behavioural detections are contributed to reporters. Those connections are disconnected either + /// way; this only controls escalation. + /// + /// + /// Keyed on bytes-received, never elapsed time: a connection that sent something and ran out of time is + /// far more likely a slow link than an attack. See dev-docs/ip-bans-and-allowlists.md. + /// + [JsonPropertyName("reportBadConnects")] + public bool ReportBadConnects { get; set; } = true; + + [JsonPropertyName("badConnectDuration")] + public TimeSpan BadConnectDuration { get; set; } = TimeSpan.FromHours(4); } diff --git a/Projects/Server/Network/Bans/BanReasons.cs b/Projects/Server/Network/Bans/BanReasons.cs new file mode 100644 index 000000000..16708f716 --- /dev/null +++ b/Projects/Server/Network/Bans/BanReasons.cs @@ -0,0 +1,48 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanReasons.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 . * + *************************************************************************/ + +namespace Server.Network.Bans; + +/// The reason slugs contributed through . Policy keys off these. +public static class BanReasons +{ + /// An operator banned this address explicitly. Never exempt, never auto-denied. + public const string Manual = "manual"; + + public const string RateLimit = "rate-limit"; + + /// Matched the reputation blocklist. Enforced by its own filter, not by behaviour. + public const string Blocklist = "blocklist"; + + /// Reaped without ever sending a byte. + public const string SilentConnect = "silent-connect"; + + /// Opened with a zero seed, which no real client sends. + public const string InvalidSeed = "invalid-seed"; + + /// Positively identified as another protocol entirely. See . + public const string ForeignProtocol = "foreign-protocol"; + + /// + /// Verdicts the shard reached by watching the connection. Only these may be exempted, and only these feed + /// the local denylist. + /// + /// + /// Opt-in rather than "everything except ", so a reason added later escalates normally + /// instead of silently inheriting an exemption. + /// + public static bool IsBehavioral(string reason) => + reason is RateLimit or SilentConnect or InvalidSeed or ForeignProtocol; +} diff --git a/Projects/Server/Network/ForeignProtocol.cs b/Projects/Server/Network/ForeignProtocol.cs new file mode 100644 index 000000000..059a69d3f --- /dev/null +++ b/Projects/Server/Network/ForeignProtocol.cs @@ -0,0 +1,146 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ForeignProtocol.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; + +namespace Server.Network; + +public enum ForeignProtocolKind +{ + None, + Http, + Tls, + Ssh +} + +public enum ForeignProtocolMatch +{ + None, + + /// A prefix matched, but more bytes are needed to be sure. + Incomplete, + + Confirmed +} + +/// +/// Identifies traffic that is positively some OTHER protocol (HTTP, TLS, SSH), rather than deciding whether +/// a connection is a good Ultima Online client. +/// +/// +/// The direction matters. A legitimate client with encryption enabled when the shard expects none sends a +/// structurally correct connection whose payload is noise, because LoginEncryption.ClientDecrypt is a +/// byte-for-byte stream XOR: length survives, content does not. So "unreadable" cannot mean "hostile", while +/// "speaks HTTP" safely can. Nothing here assumes arrival framing; see +/// dev-docs/ip-bans-and-allowlists.md. +/// +public static class ForeignProtocol +{ + private const int RequiredBytes = 8; + private const int MaxTlsRecordLength = 16384; + + public static ForeignProtocolMatch Identify(ReadOnlySpan buffer, out ForeignProtocolKind kind) + { + kind = ForeignProtocolKind.None; + + // Too little to match a prefix; the caller's own short-read handling covers it. + if (buffer.Length < 4) + { + return ForeignProtocolMatch.None; + } + + var candidate = MatchPrefix(buffer); + if (candidate == ForeignProtocolKind.None) + { + return ForeignProtocolMatch.None; + } + + if (buffer.Length < RequiredBytes) + { + return ForeignProtocolMatch.Incomplete; + } + + if (!Confirm(buffer, candidate)) + { + return ForeignProtocolMatch.None; + } + + kind = candidate; + return ForeignProtocolMatch.Confirmed; + } + + private static ForeignProtocolKind MatchPrefix(ReadOnlySpan buffer) + { + // TLS handshake record: content type 0x16, major version 3, minor version 0..4 (SSL 3.0 - TLS 1.3). + if (buffer[0] == 0x16 && buffer[1] == 0x03 && buffer[2] <= 0x04) + { + return ForeignProtocolKind.Tls; + } + + if (StartsWith(buffer, "SSH-")) + { + return ForeignProtocolKind.Ssh; + } + + // HTTP request methods. Four bytes only selects a candidate; Confirm checks the request line. + if (StartsWith(buffer, "GET ") || StartsWith(buffer, "POST") || StartsWith(buffer, "HEAD") || + StartsWith(buffer, "PUT ") || StartsWith(buffer, "OPTI") || StartsWith(buffer, "DELE") || + StartsWith(buffer, "CONN") || StartsWith(buffer, "TRAC") || StartsWith(buffer, "PATC")) + { + return ForeignProtocolKind.Http; + } + + return ForeignProtocolKind.None; + } + + private static bool Confirm(ReadOnlySpan buffer, ForeignProtocolKind candidate) + { + if (candidate == ForeignProtocolKind.Tls) + { + // A seed can collide with the 0x16 0x03 0x0? prefix (that is just the address 22.3.x.x), so + // require a ClientHello inside a plausible record. + var recordLength = (buffer[3] << 8) | buffer[4]; + return buffer[5] == 0x01 && recordLength is >= 4 and <= MaxTlsRecordLength; + } + + // A UO client's fifth byte is a packet id (0x80, 0x91, 0xEF), none of them printable, so requiring + // the request line to continue in ASCII lets a seed that spells "GET " fall through. + for (var i = 4; i < buffer.Length && i < 16; i++) + { + if (!IsPrintableAscii(buffer[i])) + { + return false; + } + } + + return true; + } + + private static bool IsPrintableAscii(byte value) => + value is >= 0x20 and <= 0x7E or (byte)'\r' or (byte)'\n' or (byte)'\t'; + + private static bool StartsWith(ReadOnlySpan buffer, string ascii) + { + for (var i = 0; i < ascii.Length; i++) + { + if (buffer[i] != (byte)ascii[i]) + { + return false; + } + } + + return true; + } +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 5262c3c2e..7a17d8cd0 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -274,7 +274,7 @@ public partial class NetState { // Enqueue-only contribution; NOT added to the local firewall set (the limiter already // gates it here and the OS bouncer drops it at the kernel). - Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, "rate-limit"); + Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, Bans.BanReasons.RateLimit); } } else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy)) @@ -362,6 +362,18 @@ public partial class NetState // Socket must have finished the entire authentication process or be forcibly disconnected if (!ns.SentFirstPacket || !ns.Seeded) { + // Only the totally silent ones are evidence. A connection that sent SOME data and ran out of + // time is far more likely a slow link, and banning those makes the player retry, trip the + // rate limiter, and compound it into an hours-long ban. + if (!ns._receivedData && Bans.BanConfiguration.Settings.ReportBadConnects) + { + Bans.BanChannel.Report( + ns.Address, + Bans.BanConfiguration.Settings.BadConnectDuration, + Bans.BanReasons.SilentConnect + ); + } + ns.Disconnect(null); // Force immediate cleanup - these are unauthenticated connections @@ -538,6 +550,11 @@ public partial class NetState return; } + if (bytesReceived > 0) + { + ns._receivedData = true; + } + // Data is already committed to buffer by RingSocketManager // Decode if encryption is enabled ns.DecryptRecvBuffer(bytesReceived); diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 05d8a8f16..7312603f7 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -60,6 +60,10 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode. * + *************************************************************************/ + +using System.Net; +using Server.Network; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.AutoDenylists; + +// Static store, so every test resets it first. Addresses come from TEST-NET-2 (198.51.100.0/24). +// Sequential: the cap tests reach Sweep, which rents from STArrayPool, which is not thread-safe. +[Collection("Sequential UOContent Tests")] +public class AutoDenylistTests +{ + private const long DurationMs = 900_000; // 15 minutes, the shipped default + private const long Now = 1_000_000; + + private static void Reset(bool enabled = true, int maxEntries = 1024) => + AutoDenylist.LoadForTesting(enabled, DurationMs, maxEntries); + + [Fact] + public void Behavioral_detection_is_held_then_lapses() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.10"); + + Assert.True(AutoDenylist.Hold(ip, BanReasons.InvalidSeed, Now)); + + Assert.True(AutoDenylist.IsDenied(ip, Now)); + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); + + // Expiry is decided on read, so the hold lapses without waiting for a sweep. + Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs)); + } + + [Fact] + public void Manual_and_list_verdicts_are_not_held() + { + Reset(); + var manual = IPAddress.Parse("198.51.100.11"); + var listed = IPAddress.Parse("198.51.100.12"); + + // A manual ban belongs in the firewall; a blocklist match is enforced by the blocklist's own filter. + Assert.False(AutoDenylist.Hold(manual, BanReasons.Manual, Now)); + Assert.False(AutoDenylist.Hold(listed, BanReasons.Blocklist, Now)); + + Assert.False(AutoDenylist.IsDenied(manual, Now)); + Assert.False(AutoDenylist.IsDenied(listed, Now)); + Assert.Equal(0, AutoDenylist.Count); + } + + [Fact] + public void Repeat_detection_extends_the_hold() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.13"); + + AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now); + AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1); + + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // would have lapsed without the second + Assert.Equal(1, AutoDenylist.Count); // and did not add a duplicate + } + + [Fact] + public void Release_drops_the_hold_immediately() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.14"); + + AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); + AutoDenylist.Release(ip); + + Assert.False(AutoDenylist.IsDenied(ip, Now)); + } + + [Fact] + public void Cap_is_enforced_so_a_distinct_source_flood_cannot_grow_it() + { + Reset(maxEntries: 4); + + for (var i = 0; i < 10; i++) + { + AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{100 + i}"), BanReasons.InvalidSeed, Now); + } + + Assert.Equal(4, AutoDenylist.Count); + + // The first four are still held; the rest were disconnected by their gate but not tracked. + Assert.True(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.100"), Now)); + Assert.False(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.109"), Now)); + } + + [Fact] + public void Reaching_the_cap_reclaims_lapsed_entries_first() + { + Reset(maxEntries: 2); + + AutoDenylist.Hold(IPAddress.Parse("198.51.100.20"), BanReasons.InvalidSeed, Now); + AutoDenylist.Hold(IPAddress.Parse("198.51.100.21"), BanReasons.InvalidSeed, Now); + + // Once those two have lapsed, a new detection sweeps them out rather than being refused. + var later = Now + DurationMs + 1; + Assert.True(AutoDenylist.Hold(IPAddress.Parse("198.51.100.22"), BanReasons.InvalidSeed, later)); + Assert.True(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.22"), later)); + } + + [Fact] + public void Disabled_store_denies_nobody() + { + Reset(enabled: false); + var ip = IPAddress.Parse("198.51.100.30"); + + Assert.False(AutoDenylist.Hold(ip, BanReasons.InvalidSeed, Now)); + Assert.False(AutoDenylist.IsDenied(ip, Now)); + } + + [Fact] + public void Null_address_is_handled() + { + Reset(); + + Assert.False(AutoDenylist.Hold(null, BanReasons.InvalidSeed, Now)); + Assert.False(AutoDenylist.IsDenied(null, Now)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs new file mode 100644 index 000000000..7e930fbdf --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -0,0 +1,122 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanExemptionsTests.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 System.Text; +using Server.Network; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Exemptions; + +// Addresses come from TEST-NET-1 (192.0.2.0/24) to stay clear of the other ban test classes if xUnit runs +// them concurrently. +public class BanExemptionsTests +{ + private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); + private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); + + private static void WithFileAllowlist(string contents) => + FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); + + private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty); + + [Fact] + public void File_allowlist_exempts_behavioral_contributions() + { + WithFileAllowlist("192.0.2.10"); + + // Subtracting from the blocklist does nothing for behavioural detections, which never consult it. + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled)); + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.SilentConnect, NeverCalled)); + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.InvalidSeed, NeverCalled)); + } + + [Fact] + public void File_allowlist_covers_cidr_entries() + { + // Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here. + WithFileAllowlist("192.0.2.0/24"); + + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); + Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); + Assert.False(BanExemptions.IsExempt(IPAddress.Parse("192.0.3.1"), BanReasons.RateLimit, AlwaysFalse)); + } + + [Fact] + public void Manual_bans_are_never_exempt_even_when_file_allowlisted() + { + WithFileAllowlist("192.0.2.10"); + + // An explicit decision outranks the operator's own carve-out, and must not cost a strike. + Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); + } + + [Fact] + public void Unopted_reasons_are_never_exempt() + { + WithFileAllowlist("192.0.2.10"); + + Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); + Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); + } + + [Fact] + public void File_allowlist_does_not_spend_the_earned_lists_strikes() + { + WithFileAllowlist("192.0.2.10"); + + // Unconditional, so the revocable list must not be consulted -- that would burn a strike. + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); + } + + [Fact] + public void Falls_through_to_the_login_allowlist_when_not_file_listed() + { + WithEmptyFileAllowlist(); + + var consulted = 0; + + var result = BanExemptions.IsExempt( + _unlisted, + BanReasons.RateLimit, + (_, _) => + { + consulted++; + return true; + } + ); + + Assert.True(result); + Assert.Equal(1, consulted); + } + + [Fact] + public void Null_address_is_never_exempt() + { + WithEmptyFileAllowlist(); + + Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); + } + + private static bool NeverCalled(IPAddress address, string reason) + { + Assert.Fail("The login allowlist must not be consulted once the answer is already decided."); + return false; + } + + private static bool AlwaysFalse(IPAddress address, string reason) => false; +} diff --git a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs new file mode 100644 index 000000000..7f3780e58 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs @@ -0,0 +1,218 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: LoginAllowlistTests.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; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.LoginAllowlists; + +// The list is static, so every test resets it first. Addresses come from TEST-NET-3 (203.0.113.0/24) so they +// cannot collide with the blocklist tests if xUnit runs the two classes at the same time. +public class LoginAllowlistTests +{ + private const long Ttl = 90 * 24 * 60 * 60; // 90 days, the shipped default + private const long Window = 3600; + private const long Now = 1_800_000_000; + + private static void Reset(bool enabled = true, int strikes = 0) => + LoginAllowlist.LoadForTesting(enabled, Ttl, strikes, Window); + + [Fact] + public void Recent_login_allows_its_address() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.10"); + + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsAllowed(ip, Now)); + Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl / 2)); + } + + [Fact] + public void Entry_lapses_once_past_the_ttl() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.11"); + + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl)); // the boundary still counts + Assert.False(LoginAllowlist.IsAllowed(ip, Now + Ttl + 1)); // a second later it does not + } + + [Fact] + public void Logging_in_again_renews_the_window() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.12"); + + LoginAllowlist.RecordLogin(ip, Now); + LoginAllowlist.RecordLogin(ip, Now + Ttl); // still allowed here, so the entry is refreshed + + Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl + Ttl)); + } + + [Fact] + public void Unknown_address_is_not_allowed() + { + Reset(); + LoginAllowlist.RecordLogin(IPAddress.Parse("203.0.113.13"), Now); + + Assert.False(LoginAllowlist.IsAllowed(IPAddress.Parse("203.0.113.14"), Now)); + } + + [Fact] + public void Private_addresses_are_never_recorded() + { + Reset(); + + // A LAN or loopback login says nothing about the public internet. + LoginAllowlist.RecordLogin(IPAddress.Parse("10.0.0.5"), Now); + LoginAllowlist.RecordLogin(IPAddress.Loopback, Now); + + Assert.False(LoginAllowlist.IsAllowed(IPAddress.Parse("10.0.0.5"), Now)); + Assert.False(LoginAllowlist.IsAllowed(IPAddress.Loopback, Now)); + Assert.Equal(0, LoginAllowlist.Count); + } + + [Fact] + public void Disabled_list_allows_nobody() + { + Reset(enabled: false); + var ip = IPAddress.Parse("203.0.113.15"); + + LoginAllowlist.RecordLogin(ip, Now); + + Assert.False(LoginAllowlist.IsAllowed(ip, Now)); + Assert.Equal(0, LoginAllowlist.Count); + } + + [Fact] + public void Null_address_is_handled() + { + Reset(); + + LoginAllowlist.RecordLogin(null, Now); + + Assert.False(LoginAllowlist.IsAllowed(null, Now)); + } + + // ----- escalation ------------------------------------------------------------------------------- + + [Fact] + public void Manual_bans_are_never_exempt() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.20"); + LoginAllowlist.RecordLogin(ip, Now); + + // An explicit decision must reach the reporters even for an allowlisted address. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.Manual, Now)); + } + + [Fact] + public void Unopted_reasons_are_never_exempt() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.21"); + LoginAllowlist.RecordLogin(ip, Now); + + // A reason nobody opted into IsBehavioral escalates normally rather than inheriting an exemption. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, "some-future-reason", Now)); + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.Blocklist, Now)); + } + + [Fact] + public void Behavioral_reasons_are_exempt_while_allowed() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.22"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.SilentConnect, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.InvalidSeed, Now)); + } + + [Fact] + public void Entry_is_revoked_once_the_strikes_run_out() + { + Reset(strikes: 3); + var ip = IPAddress.Parse("203.0.113.23"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + + // The third strike is the one that escalates, and it takes the entry with it. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.False(LoginAllowlist.IsAllowed(ip, Now)); + + // Still revoked afterwards, so everything from here escalates too. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + } + + [Fact] + public void Quiet_window_clears_the_tally() + { + Reset(strikes: 3); + var ip = IPAddress.Parse("203.0.113.24"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + + // Past the window the count restarts, so an occasional tripper never accumulates to revocation. + var later = Now + Window + 1; + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, later)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, later)); + Assert.True(LoginAllowlist.IsAllowed(ip, later)); + } + + [Fact] + public void Logging_in_again_forgives_accumulated_strikes() + { + Reset(strikes: 3); + var ip = IPAddress.Parse("203.0.113.25"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + + LoginAllowlist.RecordLogin(ip, Now); // someone proved they hold an account again + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsAllowed(ip, Now)); + } + + [Fact] + public void Zero_threshold_disables_revocation() + { + Reset(strikes: 0); + var ip = IPAddress.Parse("203.0.113.26"); + LoginAllowlist.RecordLogin(ip, Now); + + for (var i = 0; i < 50; i++) + { + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + } + + Assert.True(LoginAllowlist.IsAllowed(ip, Now)); + } +} diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs index 1e676ed9e..ea0c4b9e0 100644 --- a/Projects/UOContent/Accounting/AccountHandler.cs +++ b/Projects/UOContent/Accounting/AccountHandler.cs @@ -324,6 +324,7 @@ public static class AccountHandler e.Accepted = true; acct.LogAccess(e.State); + LoginAllowlist.RecordLogin(e.State?.Address); } } @@ -355,6 +356,7 @@ public static class AccountHandler else { acct.LogAccess(e.State); + LoginAllowlist.RecordLogin(e.State?.Address); logger.Information("Login: {NetState} Account '{Username}' at character list", e.State, un); e.State.Account = acct; diff --git a/Projects/UOContent/Commands/Generic/Commands/Commands.cs b/Projects/UOContent/Commands/Generic/Commands/Commands.cs index b3fa95350..102d5a2aa 100644 --- a/Projects/UOContent/Commands/Generic/Commands/Commands.cs +++ b/Projects/UOContent/Commands/Generic/Commands/Commands.cs @@ -1156,7 +1156,7 @@ namespace Server.Commands.Generic try { Firewall.Add(new SingleIpFirewallEntry(state.Address)); - BanChannel.Report(state.Address, TimeSpan.Zero, "manual"); + BanChannel.Report(state.Address, TimeSpan.Zero, BanReasons.Manual); AddResponse("They have been firewalled."); } catch (Exception ex) diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 1e788372e..1fe917b55 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -1745,7 +1745,7 @@ namespace Server.Gumps for (var i = 0; i < a.LoginIPs.Length; ++i) { Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i])); - BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, "manual"); + BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, BanReasons.Manual); } notice = "All addresses in the list have been firewalled."; @@ -1774,7 +1774,7 @@ namespace Server.Gumps if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) { - BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, "manual"); + BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, BanReasons.Manual); } notice = $"{toFirewall} : Added to firewall."; @@ -3596,7 +3596,7 @@ namespace Server.Gumps BanChannel.Report( firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, - "manual" + BanReasons.Manual ); } diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs new file mode 100644 index 000000000..a55280e1a --- /dev/null +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -0,0 +1,219 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AutoDenylist.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; +using System.Net; +using System.Threading; +using Server.Collections; +using Server.Logging; +using Server.Network.Bans; + +namespace Server.Network; + +/// +/// A short-lived, in-memory denylist of addresses the shard itself just caught misbehaving. +/// +/// +/// The local half of promotion. Contributing to CrowdSec only helps once an OS bouncer reacts; until then +/// every reconnect costs a socket, a buffer and a NetState slot — and the verdicts that matter most +/// are reachable only after reading bytes, like a zero seed. It is also the whole defence on a shard running +/// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban +/// without a ban's review. Only verdicts are held. +/// +public static class AutoDenylist +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist)); + + // Address (normalized v6 bits) -> Core.TickCount at which the hold lapses. Loop-only. + private static readonly Dictionary _held = []; + + private static bool _enabled; + private static long _durationMs; + private static int _maxEntries; + private static bool _warnedFull; + + public static int Count => _held.Count; + + public static void Configure() + { + AutoDenylistConfiguration.Load(); + var s = AutoDenylistConfiguration.Settings; + + _enabled = s.Enabled && s.Duration > TimeSpan.Zero && s.MaxEntries > 0; + if (!_enabled) + { + return; + } + + _durationMs = (long)s.Duration.TotalMilliseconds; + _maxEntries = s.MaxEntries; + + ConnectionFilters.Register(new AutoDenylistFilter()); + BanChannel.Register(new AutoDenylistReporter()); + } + + /// + /// Holds an address for the configured duration. Ignores non-behavioural verdicts and refuses to grow + /// past the cap: the flood this exists for must not become a memory leak. + /// + public static void Hold(IPAddress address, string reason) => Hold(address, reason, Core.TickCount); + + internal static bool Hold(IPAddress address, string reason, long nowTicks) + { + if (!_enabled || address == null || !BanReasons.IsBehavioral(reason)) + { + return false; + } + + var key = address.ToUInt128(); + + // An address already held is just extended, so no cap check is needed. + if (!_held.ContainsKey(key) && _held.Count >= _maxEntries) + { + Sweep(nowTicks); + + if (_held.Count >= _maxEntries) + { + if (!_warnedFull) + { + _warnedFull = true; + logger.Warning( + "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", + _maxEntries + ); + } + + return false; + } + } + + _held[key] = nowTicks + _durationMs; + return true; + } + + public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount); + + /// The pure decision, split out so the accept-path policy can be tested without a clock. + internal static bool IsDenied(IPAddress address, long nowTicks) + { + if (!_enabled || address == null) + { + return false; + } + + // Decided on read, so a lapsed hold cannot deny even before the sweep. Subtraction: TickCount wraps. + return _held.TryGetValue(address.ToUInt128(), out var expires) && expires - nowTicks > 0; + } + + /// Releases an address early, e.g. when an operator retracts a ban. + public static void Release(IPAddress address) + { + if (_enabled && address != null) + { + _held.Remove(address.ToUInt128()); + } + } + + internal static void Sweep(long nowTicks) + { + if (_held.Count == 0) + { + return; + } + + using var lapsed = new PooledRefList(16); + + foreach (var (address, expires) in _held) + { + if (expires - nowTicks <= 0) + { + lapsed.Add(address); + } + } + + for (var i = 0; i < lapsed.Count; i++) + { + _held.Remove(lapsed[i]); + } + + if (lapsed.Count > 0) + { + _warnedFull = false; + } + } + + internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries) + { + _held.Clear(); + _enabled = enabled; + _durationMs = durationMs; + _maxEntries = maxEntries; + _warnedFull = false; + } +} + +/// Accept-path gate for . +public sealed class AutoDenylistFilter : IConnectionFilter +{ + public string Name => "auto-denylist"; + + public void Register() + { + } + + public void Start(CancellationToken token) + { + // Only an optimisation: IsDenied expires on read. + Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), () => AutoDenylist.Sweep(Core.TickCount)); + } + + public void Stop() + { + } + + public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address); +} + +/// +/// Feeds from the ban channel. A reporter rather than a direct call, because the +/// detection sites live in the engine and must not reach into content. +/// +public sealed class AutoDenylistReporter : IBanReporter +{ + public string Name => "auto-denylist"; + + public bool CanRetract => true; + + public void Register() + { + } + + public void Start(CancellationToken token) + { + } + + public void Stop() + { + } + + /// + /// The contributed is ignored: how long a bouncer should ban an address is a + /// different question from how long this shard holds it at accept. + /// + public void Report(IPAddress address, TimeSpan ttl, string reason) => AutoDenylist.Hold(address, reason); + + public void Retract(IPAddress address) => AutoDenylist.Release(address); +} diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs new file mode 100644 index 000000000..4d45da350 --- /dev/null +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs @@ -0,0 +1,81 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AutoDenylistConfiguration.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.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network; + +/// +/// Loads the from Configuration/auto-denylist.json (matching the +/// per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file writes +/// a template so operators have something to edit. +/// +public static class AutoDenylistConfiguration +{ + private const string _path = "Configuration/auto-denylist.json"; + + public static AutoDenylistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new AutoDenylistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// Bound configuration for . +public record AutoDenylistSettings +{ + /// Whether behavioural detections are held locally. Disabled makes the filter inert. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// + /// How long an address is denied at accept after the shard catches it misbehaving. Deliberately + /// independent of the duration reported to external bouncers: this is a local holding pen, not a ban. + /// + /// + /// Short on purpose: it covers the gap before an OS bouncer reacts, and blunts a flood on shards running + /// none. An address still attacking is simply re-detected and re-added, so the list sustains itself while + /// a mistake clears on its own. + /// + [JsonPropertyName("duration")] + public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(15); + + /// + /// Hard cap on tracked addresses: a distinct-source flood is the case this exists for, so the cap is what + /// stops it becoming the exhaustion it prevents. At the cap new addresses are not tracked, but are still + /// disconnected by whichever gate detected them. + /// + [JsonPropertyName("maxEntries")] + public int MaxEntries { get; set; } = 65536; +} diff --git a/Projects/UOContent/Network/BanExemptions.cs b/Projects/UOContent/Network/BanExemptions.cs new file mode 100644 index 000000000..5566bd5e7 --- /dev/null +++ b/Projects/UOContent/Network/BanExemptions.cs @@ -0,0 +1,61 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanExemptions.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.Net; +using Server.Network.Bans; + +namespace Server.Network; + +/// +/// Combines and into the one answer +/// asks for, so neither source has to know about the other. +/// +public static class BanExemptions +{ + public static void Configure() + { + BanChannel.IsExempt = IsExempt; + } + + public static bool IsExempt(IPAddress address, string reason) => + IsExempt(address, reason, LoginAllowlist.IsExemptFromEscalation); + + /// + /// Split for testing. is stateful — calling it spends a strike — so it + /// must not be invoked once the answer is already decided. + /// + internal static bool IsExempt(IPAddress address, string reason, Func loginAllowlist) + { + if (address == null) + { + return false; + } + + // Never suppress an operator's explicit ban. Checked first so it also costs no strike. + if (!BanReasons.IsBehavioral(reason)) + { + return false; + } + + // Deliberate and unconditional, so it wins and must not spend the earned list's strikes. + if (FileAllowlist.Contains(address)) + { + return true; + } + + return loginAllowlist(address, reason); + } +} diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs similarity index 85% rename from Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs rename to Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs index ca39bd53e..8a2df5ca6 100644 --- a/Projects/UOContent/Misc/Blocklist/BlocklistConfiguration.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs @@ -67,6 +67,19 @@ public record BlocklistSettings [JsonPropertyName("file")] public string File { get; set; } = "Configuration/ip-blocklist.txt"; + /// + /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same + /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an + /// entry also suppresses ban contributions, which the generator alone cannot do. See + /// . + /// + /// + /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds + /// without anyone editing this file. + /// + [JsonPropertyName("allowlistFiles")] + public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.txt"]; + /// How often the file is checked for changes. Reloads only happen when it actually changed. [JsonPropertyName("reloadInterval")] public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistFile.cs b/Projects/UOContent/Network/Blocklist/BlocklistFile.cs similarity index 100% rename from Projects/UOContent/Misc/Blocklist/BlocklistFile.cs rename to Projects/UOContent/Network/Blocklist/BlocklistFile.cs diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs similarity index 92% rename from Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs rename to Projects/UOContent/Network/Blocklist/BlocklistFilter.cs index aeca58477..36354160b 100644 --- a/Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs @@ -133,7 +133,7 @@ public sealed class BlocklistFilter : IConnectionFilter if (shouldReport) { // Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop. - BanChannel.Report(address, _banDuration, "blocklist"); + BanChannel.Report(address, _banDuration, BanReasons.Blocklist); } return true; @@ -152,6 +152,21 @@ public sealed class BlocklistFilter : IConnectionFilter return false; } + // Both are asked only once the list has matched, so they cost the common accept nothing. The file + // list is usually redundant because the generator subtracts it — except right after an operator adds + // an entry without regenerating, which is exactly when someone is waiting to get back in. + if (FileAllowlist.Contains(address)) + { + return false; + } + + // A feed listing an address a real player logged in from recently is far more often a false positive + // than a compromise. + if (LoginAllowlist.IsAllowed(address)) + { + return false; + } + if (_reportHits) { shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs); diff --git a/Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs similarity index 96% rename from Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs rename to Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs index 52839512c..29b2e5176 100644 --- a/Projects/UOContent/Misc/Blocklist/BlocklistSnapshot.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs @@ -182,6 +182,12 @@ public sealed class BlocklistSnapshot return false; } + /// + /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for + /// whom would read backwards. The interval machinery is direction-agnostic. + /// + public bool Contains(IPAddress ip) => IsBanned(ip); + public bool IsBanned(IPAddress ip) { if (ip.IsIPv4MappedToIPv6) diff --git a/Projects/UOContent/Network/Blocklist/FileAllowlist.cs b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs new file mode 100644 index 000000000..0d5582713 --- /dev/null +++ b/Projects/UOContent/Network/Blocklist/FileAllowlist.cs @@ -0,0 +1,313 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FileAllowlist.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; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Server.Logging; + +namespace Server.Network.Bans; + +/// +/// The operator's own "leave this address alone" list, read from the same files +/// tools/Export-IpBlocklist.ps1 subtracts at generation time. +/// +/// +/// The generator already subtracts these from the blocklist, but that only covers being BLOCKED. +/// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is +/// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address +/// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the +/// next regeneration. Unconditional, unlike , but still no shield against a +/// manual ban — see . +/// +public static class FileAllowlist +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist)); + + // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile + // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. + private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; + + private static string[] _patterns = []; + private static TimeSpan _interval = TimeSpan.FromSeconds(60); + private static long _lastStamp; + private static CancellationTokenSource _cts; + + public static int Count => _snapshot.Count; + + /// True when an operator listed this address. Safe before . + public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address); + + public static void Initialize() + { + // BlocklistFilter.Register ran during the Configure sweep, so the settings are populated. + var settings = BlocklistConfiguration.Settings; + if (settings == null) + { + return; + } + + _patterns = ResolvePaths(settings.AllowlistFiles); + _interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval; + + if (_patterns.Length == 0) + { + logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)"); + return; + } + + Reload(); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token); + _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); + } + + public static void Stop() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + private static string[] ResolvePaths(string[] configured) + { + if (configured == null) + { + return []; + } + + var resolved = new string[configured.Length]; + var count = 0; + + for (var i = 0; i < configured.Length; i++) + { + var path = configured[i]; + if (string.IsNullOrWhiteSpace(path)) + { + continue; + } + + // Relative resolves against BaseDirectory, never the working directory, which differs when the + // shard is launched from elsewhere. Absolute is as-is, so shards can share a list. + resolved[count++] = Path.IsPathRooted(path) ? path : Path.Join(Core.BaseDirectory, path); + } + + Array.Resize(ref resolved, count); + return resolved; + } + + /// + /// Expands the configured patterns to actual files. Done per poll rather than once, so a carve-out an + /// admin adds is picked up without a restart. + /// + private static string[] ExpandPaths() + { + var files = new List(); + + for (var i = 0; i < _patterns.Length; i++) + { + var pattern = _patterns[i]; + var name = Path.GetFileName(pattern); + + if (name.IndexOf('*') < 0 && name.IndexOf('?') < 0) + { + if (File.Exists(pattern)) + { + files.Add(pattern); + } + + continue; + } + + try + { + var dir = Path.GetDirectoryName(pattern); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + { + continue; + } + + var matches = Directory.GetFiles(dir, name); + Array.Sort(matches, StringComparer.Ordinal); + + for (var j = 0; j < matches.Length; j++) + { + // Windows wildcard matching still honours legacy short names, so ".txt" can pull in the + // generator's ".txt.tmp" mid-swap. Check the real extension. + if (matches[j].EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + files.Add(matches[j]); + } + } + } + catch + { + // Unreadable directory; the next poll retries. + } + } + + return files.ToArray(); + } + + private static async ValueTask PollLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_interval, token); + } + catch (OperationCanceledException) + { + return; + } + + try + { + if (Stamp() != _lastStamp) + { + // A save owns the disk and nothing here is urgent. + // See the threading policy in CLAUDE.md (rules #3 and #10). + while (World.Saving || World.WorldState == WorldState.PendingSave) + { + await Task.Delay(TimeSpan.FromSeconds(1), token); + } + + Reload(); + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + logger.Warning(e, "File allowlist reload check failed; keeping last snapshot ({Count})", Count); + } + } + } + + /// + /// Change fingerprint across every configured file. A missing file contributes nothing, so creating or + /// deleting one also registers as a change. + /// + private static long Stamp() + { + var stamp = 0L; + var paths = ExpandPaths(); + + for (var i = 0; i < paths.Length; i++) + { + try + { + var info = new FileInfo(paths[i]); + if (info.Exists) + { + stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length; + } + } + catch + { + // Mid-swap by the generator; the next poll picks it up. + } + } + + return stamp; + } + + private static void Reload() + { + // Fingerprint BEFORE parsing, so it describes the version being read. Capturing after could skip a + // version; a stale fingerprint only costs an extra reload. + var stamp = Stamp(); + var combined = ReadAll(out var files); + + // Reuses the blocklist parser and interval index: an address set is direction-agnostic. + var next = combined.Length == 0 + ? BlocklistSnapshot.Empty + : BlocklistSnapshot.Build(combined, out _, out _); + + _snapshot = next; // single volatile swap; readers see old or new whole + _lastStamp = stamp; + + logger.Information( + "File allowlist loaded {Count} range(s) from {Files} file(s)", + next.Count, + files + ); + } + + /// + /// Concatenates every configured file into one buffer. The parser is line-based, so a newline join is + /// enough, and membership stays a single lookup. + /// + private static byte[] ReadAll(out int files) + { + files = 0; + + var paths = ExpandPaths(); + var chunks = new byte[paths.Length][]; + var total = 0; + + for (var i = 0; i < paths.Length; i++) + { + try + { + if (!File.Exists(paths[i])) + { + continue; + } + + var bytes = File.ReadAllBytes(paths[i]); + chunks[i] = bytes; + total += bytes.Length + 1; // + newline separator + files++; + } + catch (Exception e) + { + // Fail open per file: losing one entry beats refusing to load the rest. + logger.Warning(e, "Could not read allowlist \"{Path}\"", paths[i]); + } + } + + if (total == 0) + { + return []; + } + + var combined = new byte[total]; + var offset = 0; + + for (var i = 0; i < chunks.Length; i++) + { + var chunk = chunks[i]; + if (chunk == null) + { + continue; + } + + Buffer.BlockCopy(chunk, 0, combined, offset, chunk.Length); + offset += chunk.Length; + combined[offset++] = (byte)'\n'; + } + + return combined; + } + + internal static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot ?? BlocklistSnapshot.Empty; +} diff --git a/Projects/UOContent/Misc/Blocklist/PromotedGuard.cs b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs similarity index 100% rename from Projects/UOContent/Misc/Blocklist/PromotedGuard.cs rename to Projects/UOContent/Network/Blocklist/PromotedGuard.cs diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs similarity index 100% rename from Projects/UOContent/Misc/CrowdSec/CrowdSecAlert.cs rename to Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs similarity index 100% rename from Projects/UOContent/Misc/CrowdSec/CrowdSecAlertClient.cs rename to Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs similarity index 100% rename from Projects/UOContent/Misc/CrowdSec/CrowdSecConfiguration.cs rename to Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs diff --git a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs similarity index 99% rename from Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs rename to Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs index e38aa17a6..385534d2a 100644 --- a/Projects/UOContent/Misc/CrowdSec/CrowdSecReporter.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs @@ -363,7 +363,7 @@ public sealed class CrowdSecReporter : IBanReporter foreach (var (value, item) in byIp) { - var ttl = item.Reason == "manual" || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl; + var ttl = item.Reason == BanReasons.Manual || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl; var scenario = $"{settings.Origin}/{item.Reason}"; alerts.Add(new CrowdSecAlert diff --git a/Projects/UOContent/Misc/Firewall/BaseFirewallEntry.cs b/Projects/UOContent/Network/Firewall/BaseFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/BaseFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/BaseFirewallEntry.cs diff --git a/Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs b/Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs diff --git a/Projects/UOContent/Misc/Firewall/Firewall.cs b/Projects/UOContent/Network/Firewall/Firewall.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/Firewall.cs rename to Projects/UOContent/Network/Firewall/Firewall.cs diff --git a/Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs b/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs rename to Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs diff --git a/Projects/UOContent/Misc/Firewall/FirewallSettings.cs b/Projects/UOContent/Network/Firewall/FirewallSettings.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/FirewallSettings.cs rename to Projects/UOContent/Network/Firewall/FirewallSettings.cs diff --git a/Projects/UOContent/Misc/Firewall/IFirewallEntry.cs b/Projects/UOContent/Network/Firewall/IFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/IFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/IFirewallEntry.cs diff --git a/Projects/UOContent/Misc/Firewall/SingleIpFirewallEntry.cs b/Projects/UOContent/Network/Firewall/SingleIpFirewallEntry.cs similarity index 100% rename from Projects/UOContent/Misc/Firewall/SingleIpFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/SingleIpFirewallEntry.cs diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs new file mode 100644 index 000000000..ed65b5ec6 --- /dev/null +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -0,0 +1,385 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: LoginAllowlist.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; +using System.Globalization; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Server.Collections; +using Server.Logging; +using Server.Network.Bans; + +namespace Server.Network; + +/// +/// An allowlist addresses earn by logging in successfully, so a reputation feed cannot get a known player +/// blocked and a flaky connection cannot get one globally banned. +/// +/// +/// +/// Consulted only after the blocklist has already matched, and again before a ban is contributed, so a +/// normal accept pays nothing for it. An entry is evidence rather than a licence: enough strikes inside the +/// window revokes it. It cannot bootstrap, so it hedges stable addresses and does not replace +/// . See dev-docs/ip-bans-and-allowlists.md. +/// +/// +/// Both dictionaries are game-loop state. Only the file write runs off-loop, over a snapshot taken on the +/// loop. +/// +/// +public static class LoginAllowlist +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoginAllowlist)); + + // Address (normalized v6 bits) -> unix seconds of its last successful login. Loop-only. + private static readonly Dictionary _allowed = []; + + // Suppressed contributions in the current window. Only holds allowlisted addresses, so it is bounded by + // _allowed and cannot be grown by an attacker. + private static readonly Dictionary _strikes = []; + + private static bool _enabled; + private static string _path; + private static long _ttlSeconds; + private static int _escalateAfterStrikes; + private static long _strikeWindowSeconds; + private static bool _dirty; + + public static int Count => _allowed.Count; + + private struct Strike + { + public int Count; + public long WindowStart; // unix seconds; 0 means "no window open" + } + + public static void Configure() + { + LoginAllowlistConfiguration.Load(); + var s = LoginAllowlistConfiguration.Settings; + + _enabled = s.Enabled && !string.IsNullOrWhiteSpace(s.File) && s.Ttl > TimeSpan.Zero; + if (!_enabled) + { + return; + } + + _path = Path.IsPathRooted(s.File) ? s.File : Path.Join(Core.BaseDirectory, s.File); + _ttlSeconds = (long)s.Ttl.TotalSeconds; + _escalateAfterStrikes = s.EscalateAfterStrikes; + _strikeWindowSeconds = (long)s.StrikeWindow.TotalSeconds; + } + + public static void Initialize() + { + if (!_enabled) + { + logger.Information("Login allowlist disabled"); + return; + } + + Load(); + + var interval = LoginAllowlistConfiguration.Settings.FlushInterval; + if (interval <= TimeSpan.Zero) + { + interval = TimeSpan.FromMinutes(1); + } + + Timer.DelayCall(interval, interval, Flush); + } + + /// + /// Records a successful authentication. Private addresses are skipped: a LAN or loopback login says + /// nothing about the public internet. + /// + public static void RecordLogin(IPAddress address) => RecordLogin(address, ToUnixSeconds(Core.Now)); + + /// The pure write, split out so policy can be tested without a clock. + internal static void RecordLogin(IPAddress address, long nowUnix) + { + if (!_enabled || address == null || address.IsPrivateNetwork()) + { + return; + } + + var key = address.ToUInt128(); + _allowed[key] = nowUnix; + + // A fresh login clears the tally: someone just proved they hold an account. + _strikes.Remove(key); + _dirty = true; + } + + /// + /// True when this address logged in within the TTL. Expiry is decided on read, so a stale entry left for + /// the next flush can never allow anything. + /// + public static bool IsAllowed(IPAddress address) => IsAllowed(address, ToUnixSeconds(Core.Now)); + + /// The pure decision, split out so the TTL policy can be tested without a clock. + internal static bool IsAllowed(IPAddress address, long nowUnix) + { + if (!_enabled || address == null) + { + return false; + } + + return _allowed.TryGetValue(address.ToUInt128(), out var stamp) && nowUnix - stamp <= _ttlSeconds; + } + + public static bool IsExemptFromEscalation(IPAddress address, string reason) => + IsExemptFromEscalation(address, reason, ToUnixSeconds(Core.Now)); + + /// + /// Whether this contribution should be dropped instead of escalated, counting a strike if so. Not a pure + /// read — calling it is what spends the address's allowance. + /// + internal static bool IsExemptFromEscalation(IPAddress address, string reason, long nowUnix) + { + // An operator's explicit ban, or a reason nobody opted in, escalates untouched. + if (!BanReasons.IsBehavioral(reason) || !IsAllowed(address, nowUnix)) + { + return false; + } + + if (_escalateAfterStrikes <= 0) + { + return true; // revocation disabled: an entry is unconditional + } + + var key = address.ToUInt128(); + _strikes.TryGetValue(key, out var strike); + + if (strike.WindowStart == 0 || nowUnix - strike.WindowStart > _strikeWindowSeconds) + { + strike = new Strike { WindowStart = nowUnix }; + } + + strike.Count++; + + if (strike.Count < _escalateAfterStrikes) + { + _strikes[key] = strike; + return true; + } + + // Allowance spent: drop the entry so this and all after it escalate. Earned back by logging in. + _allowed.Remove(key); + _strikes.Remove(key); + _dirty = true; + + logger.Information( + "{Address} revoked from the login allowlist after {Count} suppressed contribution(s); last was '{Reason}'", + address, + strike.Count, + reason + ); + + return false; + } + + internal static void LoadForTesting(bool enabled, long ttlSeconds, int escalateAfterStrikes = 0, long strikeWindowSeconds = 3600) + { + _allowed.Clear(); + _strikes.Clear(); + _enabled = enabled; + _ttlSeconds = ttlSeconds; + _escalateAfterStrikes = escalateAfterStrikes; + _strikeWindowSeconds = strikeWindowSeconds; + _path = null; + } + + private static long ToUnixSeconds(DateTime utc) => (long)(utc - DateTime.UnixEpoch).TotalSeconds; + + private static void Flush() + { + if (!_enabled || !_dirty) + { + return; + } + + // A save owns the disk and nothing here is urgent. _dirty stays set, so skipping loses nothing. + // See the threading policy in CLAUDE.md (rules #3 and #10). + if (World.Saving || World.WorldState == WorldState.PendingSave) + { + return; + } + + var nowUnix = ToUnixSeconds(Core.Now); + var cutoff = nowUnix - _ttlSeconds; + + // Prune and snapshot in one loop-side pass; the writer only sees private copies. Not pooled: + // STArrayPool is single-threaded and these escape to another thread. + var addresses = new UInt128[_allowed.Count]; + var stamps = new long[_allowed.Count]; + var count = 0; + + using var expired = new PooledRefList(16); + + foreach (var (address, stamp) in _allowed) + { + if (stamp < cutoff) + { + expired.Add(address); + continue; + } + + addresses[count] = address; + stamps[count] = stamp; + count++; + } + + for (var i = 0; i < expired.Count; i++) + { + _allowed.Remove(expired[i]); + _strikes.Remove(expired[i]); + } + + PruneStaleStrikes(nowUnix); + + _dirty = false; + + var path = _path; + var total = count; + var dropped = expired.Count; + + _ = Task.Run(() => Write(path, addresses, stamps, total, dropped)); + } + + /// Drops tallies whose window has closed. + private static void PruneStaleStrikes(long nowUnix) + { + if (_strikes.Count == 0) + { + return; + } + + using var stale = new PooledRefList(16); + + foreach (var (address, strike) in _strikes) + { + if (nowUnix - strike.WindowStart > _strikeWindowSeconds) + { + stale.Add(address); + } + } + + for (var i = 0; i < stale.Count; i++) + { + _strikes.Remove(stale[i]); + } + } + + private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + // Sibling + swap, so a reader never sees a half-written list. + var tmp = path + ".tmp"; + + using (var writer = new StreamWriter(tmp, false, new UTF8Encoding(false), 1 << 16)) + { + writer.Write("# modernuo-login-allowlist generated="); + writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); + writer.Write(" count="); + writer.Write(count); + writer.Write('\n'); + + for (var i = 0; i < count; i++) + { + writer.Write(addresses[i].ToIpAddress().ToString()); + writer.Write(' '); + writer.Write(stamps[i]); + writer.Write('\n'); + } + } + + File.Move(tmp, path, true); + + if (dropped > 0) + { + logger.Information("Login allowlist wrote {Count} entr(ies), dropped {Dropped} past TTL", count, dropped); + } + } + catch (Exception e) + { + // Recoverable: entries are still in memory and the next flush retries. + logger.Warning(e, "Could not write the login allowlist to \"{Path}\"", path); + } + } + + private static void Load() + { + if (!File.Exists(_path)) + { + logger.Information("Login allowlist empty: no file at \"{Path}\"", _path); + return; + } + + var cutoff = ToUnixSeconds(Core.Now) - _ttlSeconds; + var loaded = 0; + var skipped = 0; + + try + { + foreach (var line in File.ReadLines(_path)) + { + var span = line.AsSpan().Trim(); + if (span.Length == 0 || span[0] == '#' || span[0] == ';') + { + continue; + } + + var sep = span.IndexOf(' '); + if (sep <= 0 || + !IPAddress.TryParse(span[..sep], out var address) || + !long.TryParse(span[(sep + 1)..].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var stamp)) + { + skipped++; + continue; + } + + // Expired on disk: do not carry a stranger into memory. + if (stamp < cutoff) + { + skipped++; + _dirty = true; // the file is now out of date; the next flush rewrites it + continue; + } + + _allowed[address.ToUInt128()] = stamp; + loaded++; + } + } + catch (Exception e) + { + // Fail open: an unreadable list allows nobody, which beats refusing to boot. + logger.Warning(e, "Could not read the login allowlist at \"{Path}\"; continuing with {Count}", _path, _allowed.Count); + return; + } + + logger.Information("Login allowlist loaded {Loaded} entr(ies) ({Skipped} expired or malformed)", loaded, skipped); + } +} diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs new file mode 100644 index 000000000..b3242a85f --- /dev/null +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs @@ -0,0 +1,106 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: LoginAllowlistConfiguration.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.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network; + +/// +/// Loads the from Configuration/login-allowlist.json (matching +/// the per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file +/// writes a template so operators have something to edit. +/// +public static class LoginAllowlistConfiguration +{ + private const string _path = "Configuration/login-allowlist.json"; + + public static LoginAllowlistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new LoginAllowlistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for : which addresses have recently proven they carry a +/// real player, how long that proof counts, and how much misbehaviour revokes it. +/// +/// +/// The recency window is the point. Consumer addresses are reassigned constantly, and on CGNAT the same +/// address fronts a different subscriber week to week, so a list without a TTL becomes a list of strangers. +/// +public record LoginAllowlistSettings +{ + /// Whether successful logins are recorded and consulted at all. Disabled makes the list inert. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// + /// Where the list is persisted. A relative path resolves against . + /// Plain text, one address unix-seconds pair per line, so it can be read and edited by hand. + /// Set to "" to disable. + /// + [JsonPropertyName("file")] + public string File { get; set; } = "Configuration/login-allowlist.txt"; + + /// + /// How long a successful login allowlists its address; dropped on the next flush after that. 90 days + /// covers a player who takes a season off without carrying a reassigned address indefinitely. + /// + [JsonPropertyName("ttl")] + public TimeSpan Ttl { get; set; } = TimeSpan.FromDays(90); + + /// + /// How often a changed list is written out. A crash loses at most this much, and an entry is re-earned by + /// the next login. + /// + [JsonPropertyName("flushInterval")] + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// How many suppressed contributions inside revoke an address's entry. Past + /// this it escalates like anything else until it earns a new entry by logging in again. + /// + /// + /// Generous on purpose: local defences never stop applying, so a high threshold only delays the external + /// ban. A bad line might trip a gate a few times an hour; a host being used to flood burns through this + /// in seconds. Set to 0 to never revoke. + /// + [JsonPropertyName("escalateAfterStrikes")] + public int EscalateAfterStrikes { get; set; } = 10; + + /// Rolling window the strike count is measured over. A quiet hour clears the tally. + [JsonPropertyName("strikeWindow")] + public TimeSpan StrikeWindow { get; set; } = TimeSpan.FromHours(1); +} diff --git a/dev-docs/ip-bans-and-allowlists.md b/dev-docs/ip-bans-and-allowlists.md new file mode 100644 index 000000000..5588bb19e --- /dev/null +++ b/dev-docs/ip-bans-and-allowlists.md @@ -0,0 +1,216 @@ +# IP Bans, Blocklists and Allowlists + +How a shard decides to refuse a connection, how it contributes bans to an external bouncer, and how an +operator exempts someone who was caught by mistake. + +If you are here because **a player cannot connect**, skip to [Unblocking a player](#unblocking-a-player). + +## The shape of it + +Two independent questions, deliberately separated: + +| Question | Answered by | Effect | +|---|---|---| +| Refuse this connection? | `IConnectionFilter` implementations, at accept | The socket is dropped | +| Tell the outside world about it? | `BanChannel` → `IBanReporter` implementations | CrowdSec, and from there an OS bouncer | + +`BanChannel` **never enforces** and filters **never report on each other's behalf**. Enforcement that +outlives the process belongs to the OS bouncer; the shard only contributes. + +### Refusing a connection + +Filters are consulted in registration order, first denial wins: + +| Filter | Source | Scope | +|---|---|---| +| `firewall` | `Configuration/firewall.json`, mutable in-game | Admin-curated, permanent | +| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries) | Reputation feeds | +| `auto-denylist` | In-memory, 15 min | What this shard just caught misbehaving | + +### Contributing a ban + +`BanChannel.Report(address, ttl, reason)` → `BanExemptions.IsExempt` → if not exempt, fan out to every +reporter (`crowdsec`, `auto-denylist`). + +### Allowlists + +Two, with different authority: + +| List | Source | Revocable? | Covers | +|---|---|---|---| +| `FileAllowlist` | every `ip-allowlist*.txt` | No — an operator said so | Blocking **and** escalation | +| `LoginAllowlist` | Earned by authenticating, 90-day TTL | Yes — 10 strikes/hour | Blocking **and** escalation | + +Both are consulted **only after the blocklist has already matched**, so a normal accept — the one an +attacker is trying to flood — pays nothing for them. The accept gate itself is deliberately allowlist-free: +a whitelist there could only turn a deny into an allow at the cost of a lookup on every accept, the +attacker's included. + +## Unblocking a player + +### 1. Find out what is actually blocking them + +```bash +# In the reputation blocklist? +grep -x "203.0.113.42" Distribution/Configuration/ip-blocklist.txt + +# A live external decision? (this is what survives a restart) +cscli decisions list --ip 203.0.113.42 + +# Admin-curated? +grep 203.0.113.42 Distribution/Configuration/firewall.json +``` + +If none of those match, they may be inside a **CIDR** in the blocklist, or held by the in-memory +`auto-denylist` — that one is not queryable and expires on its own within 15 minutes. + +### 2. Add them to the allowlist + +One entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This file +is yours; the generator creates it once and never rewrites it. + +``` +203.0.113.42 # shard owner, listed via a shared upstream address +198.51.100.0/24 # a whole range if the ISP rotates within it +``` + +The shard reloads within `reloadInterval` (60s default). **No restart, and no need to re-run the +generator.** From that point the address is neither blocked nor contributed. + +### 3. Clear any ban that already exists + +A config change cannot retract a ban that has already left the building: + +```bash +cscli decisions delete -i 203.0.113.42 +``` + +### 4. If it keeps coming back + +The shard is no longer contributing them, so a recurring ban is coming from CrowdSec's own sources (the +community blocklist, another watcher). Allowlist it there too: + +```bash +cscli allowlists create shard-staff -d "Known-good player addresses" +cscli allowlists add shard-staff 203.0.113.42 +``` + +### What will NOT work + +- **Deleting the CrowdSec decision alone.** If the address is still in `ip-blocklist.txt` and not + allowlisted, the next connection re-reports it within `promoteSuppression` (60s). +- **Editing `ip-blocklist.txt` by hand.** The next generator run rewrites the whole file. +- **`cscli allowlists` alone.** That is the enforcement layer. The shard's own accept gate sits upstream of + it and will still refuse the connection. + +## Why entries appear that should not + +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 — and where leases rotate, a listing says little about whoever holds the +address now. This is near-universal on mobile carriers, satellite (Starlink) and WISPs, and common on +fixed-line broadband outside North America. + +A **carve-out** exempts a whole network. **None ship with ModernUO** — which providers to exempt depends on +where your players actually are, and a carve-out names a real network, so you build the ones you need: + +```powershell +# Exempt a CGNAT provider whose players keep getting listed +.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + +# Later: bring every carve-out up to date with what those networks currently announce +.\Export-IpBlocklist.ps1 -RefreshCarveouts +``` + +That writes `ip-allowlist-starlink.txt` beside the blocklist, and every `ip-allowlist*.txt` there is +subtracted — both by the generator and by the shard, with no config edit. Starlink costs about 0.1% of the +list. Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. + +Carve-out files carry an `asn=` marker in their header, which is how `-RefreshCarveouts` finds them. A +hand-written allowlist has no marker and is never rewritten. + +**Do you need one?** If players report being blocked and they are on satellite, mobile, or an ISP short on +IPv4, probably yes. Find the ASN by looking up an address the network hands out on any public BGP lookup. + +Prefixes come from **announcements, not ownership records**. 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. + +## Behavioural detection + +Verdicts the shard reaches by watching a connection, rather than by consulting a list: + +| Reason | Trigger | +|---|---| +| `rate-limit` | Too many connection attempts in the limiter's window | +| `silent-connect` | Reaped after `ConnectingSocketIdleLimit` (5s) having sent **zero bytes** | +| `invalid-seed` | Opened with a zero seed, which no real client sends | +| `foreign-protocol` | Positively identified as HTTP, TLS or SSH | + +`BanReasons.IsBehavioral` gates two things: only these may be exempted, and only these enter the +`auto-denylist`. It is an **opt-in list**, not "everything except `manual`" — a reason added later escalates +normally rather than silently inheriting an exemption. `manual` is never exempt and never auto-denied. + +Escalation is **immediate**, on the first detection: a 15-minute local hold plus a `badConnectDuration` +(4h) contribution. There is no N-connection threshold; the strike counter governs only revoking a +`LoginAllowlist` entry. + +### What is deliberately NOT detected + +**Do not add rules based on arrival framing.** TCP has no message boundaries, so the network, the OS or a +middlebox can split the opening bytes anywhere regardless of what the client sent. A rule of the form +"these bytes must arrive together" is broken by construction and drops real players on poor links. This has +been tried and reverted before. + +**Do not treat unreadable payloads as hostile.** A legitimate client with encryption enabled when the shard +expects none sends a structurally perfect connection whose payload is noise — `LoginEncryption.ClientDecrypt` +is a byte-for-byte stream XOR, so it preserves length exactly while destroying content. This is why +detection asks "is this positively some *other* protocol?" rather than "is this a good UO client?": however +misconfigured a UO client is, it never sends `GET / HTTP/1.1`. + +**Timeouts are keyed on bytes-received, not elapsed time.** A connection that sent *something* and ran out +of time is far more likely a slow link than an attack. 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. Shortening the 5s handshake window has been tried and broke real players. + +## Known limits + +- **An allowlist cannot bootstrap.** A `LoginAllowlist` entry is only earned by getting in, so it can never + repair an existing false positive, and it is weakest on rotating CGNAT — a player whose lease moved is a + stranger again. `FileAllowlist` is the fix for that, which is why it is manual. +- **A never-logged-in player on a shared address can still be caught**, for up to `badConnectDuration`, if a + co-tenant misbehaves. Accepted: it is 4h and self-healing. The cheapest lever is `badConnectDuration`. +- **`MaxConnections` (4096) is a hard ceiling.** The accept gate runs *after* the kernel completed the TCP + handshake, so a blocklist match saves the socket setup and the `NetState` slot but never the connection + itself. Only an upstream L4 proxy or edge scrubbing moves that cost off the shard. + +## Configuration + +| File | Controls | +|---|---| +| `bans.json` | `reportRateLimitTrips`, `autoBanDuration`, `reportBadConnects`, `badConnectDuration` | +| `blocklist.json` | `file`, `allowlistFiles` (wildcards allowed), `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | +| `login-allowlist.json` | `enabled`, `file`, `ttl`, `flushInterval`, `escalateAfterStrikes`, `strikeWindow` | +| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` | +| `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | +| `firewall.json` | Admin-curated entries | + +A shard fronted by an upstream proxy can disable all of it and register nothing. + +## Key files + +| File | Role | +|---|---| +| `Projects/Server/Network/IConnectionFilter.cs` | Accept-path gate contract | +| `Projects/Server/Network/ConnectionFilters.cs` | Filter registry + lifecycle | +| `Projects/Server/Network/ForeignProtocol.cs` | Positive identification of non-UO traffic | +| `Projects/Server/Network/Bans/BanChannel.cs` | Contribution fan-out + `IsExempt` seam | +| `Projects/Server/Network/Bans/BanReasons.cs` | Reason slugs + the behavioural opt-in set | +| `Projects/UOContent/Network/BanExemptions.cs` | Combines both allowlists into one answer | +| `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | +| `Projects/UOContent/Network/Blocklist/FileAllowlist.cs` | Operator carve-outs, read from the allowlist files | +| `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by authenticating | +| `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold | +| `Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs` | LAPI contribution sink | +| `Projects/UOContent/Network/Firewall/Firewall.cs` | Admin-curated firewall set | +| `tools/Export-IpBlocklist.ps1` | Blocklist generator + allowlist subtraction | diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index 5eed62249..af47a1e06 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -509,10 +509,15 @@ Rules: - 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 two that ship are `firewall` -(admin-curated, mutable at runtime, persisted to `Configuration/firewall.json`) and `blocklist` -(file-sourced, millions of entries, demand-pages hits to CrowdSec). A shard that fronts its server with -an upstream proxy or edge scrubbing can drop both and register nothing. +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`](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 @@ -553,6 +558,10 @@ those, so the helpers check both. | `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/Misc/Firewall/Firewall.cs` | Admin-curated firewall set | +| `Projects/UOContent/Network/Firewall/Firewall.cs` | Admin-curated firewall set | | `Projects/Server/Utilities/IPAddressUtility.cs` | IPAddress <-> UInt128 normalization, CIDR parsing | -| `Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | +| `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) | diff --git a/tools/Export-IpBlocklist.ps1 b/tools/Export-IpBlocklist.ps1 index 81b9afd06..0e9fd781d 100644 --- a/tools/Export-IpBlocklist.ps1 +++ b/tools/Export-IpBlocklist.ps1 @@ -34,7 +34,35 @@ player -- and those are barely present here anyway (bitwire is ~5% of VPN-tunnel lists). If you ever want to protect VPN/Tor players, pass -ExcludeAnonymizers to subtract Tor/open-proxy/VPN IPs from the output. - OUTPUT FORMAT (must stay in sync with UOContent/Misc/Blocklist/BlocklistFile.cs): + ALLOWLIST + Aggregators inevitably list shared consumer address space. A CGNAT public IP fronts many subscribers, so + one abusive customer gets the address listed and every other subscriber behind it is blocked with them. + Entries in the allowlist file (-AllowlistFile) are SUBTRACTED from the merged set before it is written, so + the exemption costs nothing on the shard's accept path -- which is deliberately allowlist-free, because a + whitelist there could only ever turn a deny into an allow at the price of a lookup on every accept, the + attacker's included. Allowlisting belongs here (at generation) and at the enforcement layer + (`cscli allowlists`), never at the gate. + + Subtraction is range-correct: an allowlisted address that falls inside a blocked CIDR splits that CIDR + around the hole instead of being silently ignored, so an exemption always takes effect no matter which + shape the feed happened to publish. + + Every `ip-allowlist*.txt` beside the output is subtracted, so allowlists are split by owner rather than + kept in one file: `ip-allowlist.txt` holds the operator's own exemptions and is never rewritten, while + network carve-outs live in `ip-allowlist-.txt`. Keeping them apart means a carve-out can be + regenerated, diffed or copied to another shard without disturbing hand-written entries. + + NO CARVE-OUT IS SHIPPED. Which providers to exempt is a policy call that depends on where a shard's + players actually are, and a carve-out names a real network, so this script builds them on request rather + than publishing anyone's. A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on + IPv4 -- will usually want one: + + .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + + That costs roughly 0.1% of the list. Abusive hosts inside a carved-out network are still caught on + BEHAVIOR by the rate limiter and promoted to CrowdSec, which is the gate that actually observes them. + + OUTPUT FORMAT (must stay in sync with UOContent/Network/Blocklist/BlocklistFile.cs): Line 1 is a header comment carrying the version markers, e.g. # modernuo-blocklist generated=2026-07-25T18:03:11Z count=3914022 ipv4=3901188 cidr=12834 The shard polls `reloadInterval` and reloads when the file mtime AND `generated=` change, @@ -84,6 +112,46 @@ .PARAMETER Feeds Which feeds to include (by Name). Default: all of them. +.PARAMETER AllowlistFile + One or more lists of addresses that must NEVER be blocked; every entry is subtracted from the merged set + before the output is written. Same format as the blocklist: one bare IPv4 or CIDR per line, `#`/`;` + comments ignored. + + Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set: + ip-allowlist.txt operator exemptions -- created on first run, never rewritten + ip-allowlist-.txt a network carve-out -- generated data, safe to regenerate or copy + Discovered rather than configured, so a carve-out you add is picked up with no further edits. Blank a + file (keep the file) to disable its contents; delete it to drop it entirely. + + Passing this parameter replaces the defaults entirely; an explicitly-named file that does not exist is a + warning rather than a silent template write, so a typo cannot look like it worked. Pass `''` to disable + subtraction altogether. + + Editing any allowlist also bypasses -MinInterval on the next run: an exemption you just added would + otherwise sit unapplied for up to the cooldown, which reads exactly like the allowlist not working. + +.PARAMETER AddCarveout + Build a carve-out for a network and start subtracting it. Takes a short name for the file and -Asn for + the network, fetches that ASN's current routing announcements, collapses them, and writes + `ip-allowlist-.txt` beside the output. Implies -Force. + + .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + + No carve-out ships with this script: naming a network to exempt is a policy call for the shard, so they + are built on request rather than published here. Re-running with the same name rebuilds the file. + +.PARAMETER Asn + Autonomous system number for -AddCarveout, e.g. 14593 for Starlink. Look one up by querying an address + the network hands out, or on any public BGP lookup. + +.PARAMETER RefreshCarveouts + Re-fetch every carve-out beside the output and rewrite it from current routing announcements. Implies + -Force. Carve-outs are recognised by the `asn=` marker in their header, so a hand-written allowlist is + left alone, and one whose fetch fails keeps the data it already had. + + Announcements rather than ownership records on purpose: registry data disagrees with what is actually + routed, and registry queries silently cap their result sets. + .PARAMETER ExcludeAnonymizers Also download Tor-exit / open-proxy / VPN-tunnel lists and SUBTRACT those IPs from the output. Off by default -- for a game server, Tor/open-proxy relays are attack infrastructure you want to block. Turn @@ -106,6 +174,21 @@ # Regenerate right now, ignoring the cooldown. .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' -Force +.EXAMPLE + # Unblock a player caught by a shared-IP listing: add the address, then regenerate. Editing the + # allowlist bypasses the cooldown, so no -Force is needed. + Add-Content 'C:\Shard\Distribution\Configuration\ip-allowlist.txt' '203.0.113.42' + .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' + +.EXAMPLE + # Check what an allowlist would cost before committing to it. + .\Export-IpBlocklist.ps1 -AllowlistFile 'D:\shared\allow.txt' -DryRun + +.EXAMPLE + # Exempt a CGNAT provider whose players keep getting listed, then keep it current. + .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + .\Export-IpBlocklist.ps1 -RefreshCarveouts + .EXAMPLE # Linux/macOS, e.g. from cron: pwsh -File /opt/modernuo/Export-IpBlocklist.ps1 -DistributionPath /opt/modernuo/Distribution @@ -120,6 +203,10 @@ param( [string] $DistributionPath, [string] $OutFile, [string] $MinInterval = '2h', + [string[]] $AllowlistFile, + [string] $AddCarveout, + [int] $Asn, + [switch] $RefreshCarveouts, [string[]] $Feeds, [switch] $ExcludeAnonymizers, [switch] $Force, @@ -127,6 +214,11 @@ param( ) $ErrorActionPreference = 'Stop' + +# Fail before any download rather than after 60MB of feeds. +if ($AddCarveout -and $Asn -le 0) { + throw "-AddCarveout needs -Asn, e.g. -AddCarveout starlink -Asn 14593" +} $UA = 'ModernUO-Blocklist-Export' $totalSw = [System.Diagnostics.Stopwatch]::StartNew() @@ -153,6 +245,132 @@ if (-not $OutFile) { $OutFile = Join-Path $DistributionPath @DefaultPathSegments } +# --------------------------------------------------------------------------------------------------------- +# Network carve-outs are DATA, not code: this script ships none. Which providers a shard exempts is a policy +# call that depends on where its players actually are, so the carve-outs live in files an admin creates with +# -AddCarveout, and every ip-allowlist*.txt beside the output is subtracted. +# +# A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on IPv4 -- will usually want one: +# .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 +# --------------------------------------------------------------------------------------------------------- + +# --------------------------------------------------------------------------------------------------------- +# Resolve the allowlists. They sit beside the output by default so relocating the blocklist keeps the set +# together, and they are split by owner: `ip-allowlist.txt` is the operator's -- hand-edited, never rewritten +# -- while each carve-out file is generated data that can be regenerated, diffed or copied between shards +# without touching anyone's local exemptions. +# +# Carve-outs are discovered rather than listed, so a file an admin drops in is picked up with no config edit +# and no code change. An EXPLICIT -AllowlistFile replaces the whole set and is never templated: if the +# operator names a file, a missing one is a typo worth hearing about. +# --------------------------------------------------------------------------------------------------------- +$AllowGlob = 'ip-allowlist*.txt' +$ConfigDir = Split-Path -Parent $OutFile + +$allowExplicit = $PSBoundParameters.ContainsKey('AllowlistFile') + +if ($allowExplicit) { + $AllowPaths = @($AllowlistFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} +else { + $AllowPaths = @(Get-ChildItem -Path $ConfigDir -Filter $AllowGlob -File -ErrorAction SilentlyContinue | + Sort-Object Name | ForEach-Object { $_.FullName }) +} + +function Write-AllowlistFile { + param([string]$Path, [string[]]$Lines) + + $dir = Split-Path -Parent $Path + if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + + # Same atomic write the blocklist gets: a half-written allowlist would silently under-subtract. + $tmp = $Path + '.tmp' + [IO.File]::WriteAllLines($tmp, $Lines, [System.Text.UTF8Encoding]::new($false)) + [IO.File]::Move($tmp, $Path, $true) +} + +$OperatorTemplate = @' +# ModernUO blocklist allowlist -- every entry here is SUBTRACTED from the generated blocklist. +# +# This file is yours. Export-IpBlocklist.ps1 creates it once and never rewrites it, so anything you add +# survives every regeneration. +# +# One entry per line: a bare IPv4 address (1.2.3.4) or a CIDR (1.2.3.0/24). Lines starting with '#' or ';' +# are comments. Order does not matter. Re-run Export-IpBlocklist.ps1 to apply changes -- editing this file +# bypasses the -MinInterval cooldown, so no -Force is needed. +# +# Removal is range-correct: an address listed here is removed even when a feed published it as part of a +# larger CIDR -- that CIDR is split around the hole rather than dropped wholesale or silently ignored. +# +# Network carve-outs live in their own ip-allowlist-.txt beside this one (see -AddCarveout), so they +# can be regenerated or copied between shards without touching anything you put here. +# +# Put player/staff exemptions below, one per line, e.g.: +# 203.0.113.42 # shard owner, listed via a shared upstream address +'@ + +# Carve-out files carry their own `asn=` marker, so -RefreshCarveouts can rebuild whatever an admin created +# without this script keeping a list of anyone's networks. +function Get-CarveoutHeader { + param([string]$Name, [int]$CarveoutAsn) + + @( + ("# {0} carve-out (asn={1}) -- subtracted from the generated blocklist." -f $Name, $CarveoutAsn) + "#" + "# Reputation feeds list shared consumer address space constantly, so a hit inside a CGNAT network" + "# says little about the player currently behind it. Abusive hosts here are still caught on BEHAVIOR." + "#" + "# GENERATED DATA -- safe to regenerate, diff, or copy to another shard. Blank the file (keep the" + "# file) to reputation-block this network again; delete it to stop carving it out entirely." + "#" + "# Refresh with: .\Export-IpBlocklist.ps1 -RefreshCarveouts" + ) +} + +# Fetches a network's currently ANNOUNCED prefixes and collapses them. Routing data, not a registry: +# ownership records disagree with what is actually announced, and registry queries cap their result sets. +function Get-CarveoutPrefixes { + param([int]$CarveoutAsn) + + $url = "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS$CarveoutAsn" + $json = Get-Url -Url $url -Label ("AS{0} prefixes" -f $CarveoutAsn) | ConvertFrom-Json + + $v4 = @($json.data.prefixes.prefix | Where-Object { $_ -and $_ -notmatch ':' }) + if (-not $v4) { throw "AS$CarveoutAsn announced no IPv4 prefixes -- refusing to overwrite the carve-out." } + + [BlocklistExporter]::CollapsePrefixes(($v4 -join "`n")) +} + +# Reads the `asn=` marker back out of a carve-out file. Anything without one is a hand-written allowlist and +# is left alone by -RefreshCarveouts. +function Get-CarveoutAsn { + param([string]$Path) + + foreach ($line in (Get-Content -LiteralPath $Path -TotalCount 5 -ErrorAction SilentlyContinue)) { + if ($line -match 'asn=(\d+)') { return [int]$Matches[1] } + } + + return 0 +} + +# The operator's own list is the one file this script will create unprompted; carve-outs are opt-in. +if (-not $allowExplicit) { + $operatorPath = Join-Path $ConfigDir 'ip-allowlist.txt' + if (-not (Test-Path -LiteralPath $operatorPath -PathType Leaf)) { + Write-AllowlistFile -Path $operatorPath -Lines @($OperatorTemplate) + Write-Host ("Created allowlist at {0} (add player/staff exemptions here)." -f $operatorPath) + $AllowPaths = @($operatorPath) + $AllowPaths + } +} +else { + $AllowPaths = @($AllowPaths | ForEach-Object { + if (Test-Path -LiteralPath $_ -PathType Leaf) { return $_ } + Write-Warning ("Allowlist '{0}' does not exist -- nothing will be subtracted from it. Check the path." -f $_) + }) +} + # --------------------------------------------------------------------------------------------------------- # Cooldown gate. Runs BEFORE anything is downloaded: the whole point is that a misconfigured scheduler or a # retry loop cannot spam the upstream feeds. State lives in the output file itself (`generated=` header, @@ -215,17 +433,40 @@ function Get-BlocklistAge { } $minAge = ConvertTo-Duration $MinInterval -if (-not $Force -and $minAge -gt [TimeSpan]::Zero) { +# Asking for carve-out data implies -Force: waiting out the cooldown and leaving the old data in place would +# be the wrong answer. +if (-not $Force -and -not $RefreshCarveouts -and -not $AddCarveout -and $minAge -gt [TimeSpan]::Zero) { $existing = Get-BlocklistAge -Path $OutFile if ($existing) { # A negative age means the stamp is in the future (clock skew, or a file from another host). Treat it # as fresh: refusing to run is the recoverable failure, hammering the feeds on every tick is not. if ($existing.Age -lt $minAge) { - $agoText = if ($existing.Age -lt [TimeSpan]::Zero) { 'in the future -- check the clock' } else { ("{0:N1}h ago" -f $existing.Age.TotalHours) } - Write-Host ("Blocklist at {0} was generated {1} ({2}={3}); newer than -MinInterval {4}." -f ` - $OutFile, $agoText, $existing.Source, $existing.Stamp, $MinInterval) - Write-Host "Nothing downloaded. Pass -Force to regenerate now, or lower -MinInterval." - return + # An allowlist edited since the list was built is the one case where waiting out the cooldown is + # the wrong answer: the operator is unblocking someone, and "nothing happened" is indistinguishable + # from the allowlist not working. Cheap to honour -- it can only ever shrink the output. + $changedAllow = $null + $builtAt = [DateTime]::UtcNow - $existing.Age + foreach ($p in $AllowPaths) { + try { + if ((Get-Item -LiteralPath $p -ErrorAction Stop).LastWriteTimeUtc -gt $builtAt) { + $changedAllow = $p + break + } + } + catch { } + } + + if ($changedAllow) { + Write-Host ("Allowlist {0} changed since the blocklist was built; regenerating despite -MinInterval {1}." -f ` + $changedAllow, $MinInterval) + } + else { + $agoText = if ($existing.Age -lt [TimeSpan]::Zero) { 'in the future -- check the clock' } else { ("{0:N1}h ago" -f $existing.Age.TotalHours) } + Write-Host ("Blocklist at {0} was generated {1} ({2}={3}); newer than -MinInterval {4}." -f ` + $OutFile, $agoText, $existing.Source, $existing.Stamp, $MinInterval) + Write-Host "Nothing downloaded. Pass -Force to regenerate now, or lower -MinInterval." + return + } } } } @@ -328,6 +569,244 @@ public static class BlocklistExporter return added; } + // --------------------------------------------------------------------------------------------------- + // Allowlist subtraction. Allow entries become sorted, merged [start,end] ranges once; the blocklist is + // then filtered against them. The interesting case is a blocked CIDR that only PARTIALLY overlaps an + // allow range -- dropping it whole would unblock far more than asked, keeping it whole would ignore the + // exemption, so it is split into the surviving pieces and re-emitted as minimal CIDRs. + // --------------------------------------------------------------------------------------------------- + public sealed class RangeSet + { + public uint[] Start; + public uint[] End; + public int Count; + } + + public sealed class AllowResult + { + public int SinglesRemoved; + public int CidrsDropped; + public int CidrsSplit; + public int EntriesAdded; + } + + // Parses "a.b.c.d/p" to an inclusive range. The base is masked to the prefix, so a sloppy 1.2.3.5/24 + // means the whole 1.2.3.0/24 -- the standard reading, and the safe direction for an exemption. + static bool TryCidrRange(string c, out ulong lo, out ulong hi) + { + lo = 0; hi = 0; + int slash = c.IndexOf('/'); + if (slash <= 0) return false; + uint ip; + if (!TryParseIPv4(c, 0, slash, out ip)) return false; + int bits = 0, bd = 0; + for (int i = slash + 1; i < c.Length; i++) + { + char ch = c[i]; + if (ch < '0' || ch > '9') { bd = -1; break; } + bits = bits * 10 + (ch - '0'); bd++; + } + if (bd <= 0 || bits > 32) return false; + // 1u << 32 is undefined in C# (the shift count is masked to 5 bits), so /0 is special-cased. + uint mask = (bits == 0) ? 0u : ~((uint)((1UL << (32 - bits)) - 1UL)); + ulong size = (bits == 0) ? 0x100000000UL : (1UL << (32 - bits)); + lo = ip & mask; + hi = lo + size - 1UL; + return true; + } + + public static RangeSet BuildRanges(HashSet singles, HashSet cidrs) + { + uint[] s = new uint[singles.Count + cidrs.Count]; + uint[] e = new uint[s.Length]; + int k = 0; + + foreach (uint v in singles) { s[k] = v; e[k] = v; k++; } + foreach (string c in cidrs) + { + ulong lo, hi; + if (!TryCidrRange(c, out lo, out hi)) continue; + s[k] = (uint)lo; + e[k] = (uint)(hi > 0xFFFFFFFFUL ? 0xFFFFFFFFUL : hi); + k++; + } + + Array.Resize(ref s, k); + Array.Resize(ref e, k); + Array.Sort(s, e); + + // Coalesce overlapping AND adjacent ranges so the lookups below can assume disjoint, ordered spans. + int w = 0; + for (int i = 0; i < k; i++) + { + if (w > 0 && (ulong)s[i] <= (ulong)e[w - 1] + 1UL) + { + if (e[i] > e[w - 1]) e[w - 1] = e[i]; + } + else + { + s[w] = s[i]; e[w] = e[i]; w++; + } + } + + return new RangeSet { Start = s, End = e, Count = w }; + } + + // Index of the first range whose End >= v (ranges are disjoint and sorted, so End is sorted too). + static int FirstEndAtLeast(uint[] re, int n, uint v) + { + int lo = 0, hi = n; + while (lo < hi) + { + int mid = (int)(((uint)lo + (uint)hi) >> 1); + if (re[mid] < v) lo = mid + 1; else hi = mid; + } + return lo; + } + + static bool Covered(uint[] rs, uint[] re, int n, uint v) + { + int i = FirstEndAtLeast(re, n, v); + return i < n && rs[i] <= v; + } + + static string FormatCidr(uint ip, int bits) + { + char[] buf = new char[19]; + int p = 0; + p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, ip & 255); buf[p++] = '/'; + p = WriteOctet(buf, p, (uint)bits); + return new string(buf, 0, p); + } + + // Writes [lo,hi] as the minimal set of aligned CIDR blocks. A /32 goes back to the singles set so the + // output keeps the file's convention of bare addresses for single hosts. + static void Emit(ulong lo, ulong hi, HashSet singles, HashSet cidrs, AllowResult r) + { + while (lo <= hi) + { + int bits = 32; + while (bits > 0) + { + ulong size = 1UL << (32 - (bits - 1)); + if ((lo % size) != 0UL) break; + if (lo + size - 1UL > hi) break; + bits--; + } + + if (bits == 32) + { + if (singles.Add((uint)lo)) r.EntriesAdded++; + } + else if (cidrs.Add(FormatCidr((uint)lo, bits))) + { + r.EntriesAdded++; + } + + lo += 1UL << (32 - bits); + } + } + + public static AllowResult ApplyAllowlist(HashSet singles, HashSet cidrs, RangeSet allow) + { + var r = new AllowResult(); + if (allow == null || allow.Count == 0) return r; + + uint[] rs = allow.Start, re = allow.End; + int n = allow.Count; + + // Singles first: the CIDR pass below can add new singles, and those are outside the allow ranges by + // construction, so re-testing them would be wasted work. + uint[] sarr = new uint[singles.Count]; + singles.CopyTo(sarr); + for (int i = 0; i < sarr.Length; i++) + { + if (Covered(rs, re, n, sarr[i]) && singles.Remove(sarr[i])) r.SinglesRemoved++; + } + + string[] carr = new string[cidrs.Count]; + cidrs.CopyTo(carr); + cidrs.Clear(); + + for (int i = 0; i < carr.Length; i++) + { + string c = carr[i]; + ulong lo, hi; + + // Unparseable entries are kept verbatim rather than dropped: this pass exists to subtract, and + // silently discarding something it could not read would weaken the list. + if (!TryCidrRange(c, out lo, out hi)) { cidrs.Add(c); continue; } + if (hi > 0xFFFFFFFFUL) hi = 0xFFFFFFFFUL; + + int idx = FirstEndAtLeast(re, n, (uint)lo); + if (idx >= n || (ulong)rs[idx] > hi) { cidrs.Add(c); continue; } // no overlap: the common case + + ulong cursor = lo; + int before = r.EntriesAdded; + for (int j = idx; j < n && (ulong)rs[j] <= hi; j++) + { + if ((ulong)rs[j] > cursor) Emit(cursor, (ulong)rs[j] - 1UL, singles, cidrs, r); + ulong next = (ulong)re[j] + 1UL; + if (next > cursor) cursor = next; + if (cursor > hi) break; + } + if (cursor <= hi) Emit(cursor, hi, singles, cidrs, r); + + if (r.EntriesAdded == before) r.CidrsDropped++; else r.CidrsSplit++; + } + + return r; + } + + static string FormatIp(uint ip) + { + char[] buf = new char[16]; + int p = 0; + p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, ip & 255); + return new string(buf, 0, p); + } + + // Collapses a prefix list into the minimal equivalent set, in ascending order. Used by + // -RefreshCarveouts: routing data publishes thousands of overlapping announcements. + public static string[] CollapsePrefixes(string content) + { + uint[] noBogon = new uint[0]; + var singles = new HashSet(); + var cidrs = new HashSet(); + AddContent(content, singles, cidrs, noBogon, noBogon); + + var ranges = BuildRanges(singles, cidrs); + var result = new List(); + + for (int i = 0; i < ranges.Count; i++) + { + ulong lo = ranges.Start[i], hi = ranges.End[i]; + + while (lo <= hi) + { + int bits = 32; + while (bits > 0) + { + ulong size = 1UL << (32 - (bits - 1)); + if ((lo % size) != 0UL) break; + if (lo + size - 1UL > hi) break; + bits--; + } + + result.Add(bits == 32 ? FormatIp((uint)lo) : FormatCidr((uint)lo, bits)); + lo += 1UL << (32 - bits); + } + } + + return result.ToArray(); + } + static int WriteOctet(char[] buf, int pos, uint v) { if (v >= 100) { buf[pos++] = (char)('0' + v / 100); buf[pos++] = (char)('0' + (v / 10) % 10); } @@ -449,6 +928,37 @@ function Get-Url { finally { $resp.Close() } } +# --------------------------------------------------------------------------------------------------------- +# Refresh carve-out data from routing announcements. Runs here because it needs Get-Url and the compiled +# collapser. A failure leaves the existing file alone rather than truncating a working carve-out. +# --------------------------------------------------------------------------------------------------------- +if ($AddCarveout) { + $path = Join-Path $ConfigDir ("ip-allowlist-{0}.txt" -f $AddCarveout) + $prefixes = Get-CarveoutPrefixes -CarveoutAsn $Asn + + Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $AddCarveout -CarveoutAsn $Asn) + $prefixes) + Write-Host ("Created {0} carve-out from AS{1}: {2} prefixes -> {3}" -f $AddCarveout, $Asn, @($prefixes).Count, $path) + + if ($AllowPaths -notcontains $path) { $AllowPaths += $path } +} + +if ($RefreshCarveouts) { + foreach ($path in $AllowPaths) { + $carveoutAsn = Get-CarveoutAsn -Path $path + if ($carveoutAsn -le 0) { continue } # hand-written allowlist, not ours to rewrite + + try { + $name = [IO.Path]::GetFileNameWithoutExtension($path) -replace '^ip-allowlist-', '' + $prefixes = Get-CarveoutPrefixes -CarveoutAsn $carveoutAsn + Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $name -CarveoutAsn $carveoutAsn) + $prefixes) + Write-Host ("Refreshed {0} carve-out from AS{1}: {2} prefixes" -f $name, $carveoutAsn, @($prefixes).Count) + } + catch { + Write-Warning ("AS{0}: refresh failed ({1}) -- keeping the existing carve-out" -f $carveoutAsn, $_.Exception.Message) + } + } +} + # --------------------------------------------------------------------------------------------------------- # Collect every kept feed into ONE global set, timing each phase. # --------------------------------------------------------------------------------------------------------- @@ -483,18 +993,45 @@ foreach ($feed in $AllFeeds) { } # --------------------------------------------------------------------------------------------------------- -# Optional: subtract Tor / open-proxy / VPN IPs. +# Subtraction pass: the allowlist file, plus the anonymizer feeds when -ExcludeAnonymizers is set. Both are +# "never block these", so they share one set and one range-correct removal -- which is also the fix for the +# old anonymizer path, where CIDR entries were parsed and then never actually subtracted. +# +# Bogon filtering is deliberately NOT applied here: it exists to keep junk OUT of the blocklist, and running +# it over subtractive input would quietly discard exemptions instead (e.g. a shard exempting its own LAN). # --------------------------------------------------------------------------------------------------------- +$allowSingles = [System.Collections.Generic.HashSet[uint32]]::new() +$allowCidrs = [System.Collections.Generic.HashSet[string]]::new() +$noBogon = [uint32[]]::new(0) + +foreach ($p in $AllowPaths) { + try { + $allowText = Get-Content -LiteralPath $p -Raw -ErrorAction Stop + if (-not $allowText) { continue } + + $before = $allowSingles.Count + $allowCidrs.Count + [void][BlocklistExporter]::AddContent($allowText, $allowSingles, $allowCidrs, $noBogon, $noBogon) + Write-Host (" allowlist {0,-28} +{1} entr(ies)" -f (Split-Path -Leaf $p), (($allowSingles.Count + $allowCidrs.Count) - $before)) + } + catch { Write-Warning ("Could not read allowlist {0}: {1}" -f $p, $_.Exception.Message) } +} + if ($ExcludeAnonymizers) { - $anon = [System.Collections.Generic.HashSet[uint32]]::new() - $anonCidr = [System.Collections.Generic.HashSet[string]]::new() foreach ($url in $AnonFeeds) { - try { [void][BlocklistExporter]::AddContent((Get-Url -Url $url -Label 'anonymizers'), $anon, $anonCidr, $bogStart, $bogEnd) } + try { [void][BlocklistExporter]::AddContent((Get-Url -Url $url -Label 'anonymizers'), $allowSingles, $allowCidrs, $noBogon, $noBogon) } catch { Write-Warning ("anonymizer list {0}: {1}" -f $url, $_.Exception.Message) } } - $removed = 0 - foreach ($ip in @($anon)) { if ($singles.Remove($ip)) { $removed++ } } - Write-Host ("ExcludeAnonymizers: removed {0} Tor/proxy/VPN single IPs" -f $removed) +} + +$allowCount = $allowSingles.Count + $allowCidrs.Count +if ($allowCount -gt 0) { + $aSw = [System.Diagnostics.Stopwatch]::StartNew() + $ranges = [BlocklistExporter]::BuildRanges($allowSingles, $allowCidrs) + $res = [BlocklistExporter]::ApplyAllowlist($singles, $cidrs, $ranges) + $aSw.Stop() + + Write-Host ("Allowlist: {0} entr(ies) -> {1} ranges; removed {2} IPs, dropped {3} CIDRs, split {4} into {5} ({6:N1}s)" -f ` + $allowCount, $ranges.Count, $res.SinglesRemoved, $res.CidrsDropped, $res.CidrsSplit, $res.EntriesAdded, $aSw.Elapsed.TotalSeconds) } $total = $singles.Count + $cidrs.Count @@ -521,7 +1058,9 @@ if ($outDir -and -not (Test-Path -LiteralPath $outDir -PathType Container)) { # InvariantCulture: ':' is the culture-defined time separator in a custom format string, and the # header is a machine-read marker the shard compares verbatim. $generated = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) -$header = "# modernuo-blocklist generated=$generated count=$total ipv4=$($singles.Count) cidr=$($cidrs.Count) feeds=$feedCount" +# The shard's header reader is token-based and ignores tokens it does not know, so `allow=` is additive -- +# it is here so an operator can tell from the file alone whether a carve-out was in effect when it was built. +$header = "# modernuo-blocklist generated=$generated count=$total ipv4=$($singles.Count) cidr=$($cidrs.Count) feeds=$feedCount allow=$allowCount" $tmp = $OutFile + '.tmp' $wSw = [System.Diagnostics.Stopwatch]::StartNew() From 86df62fd3eaab6dccf86c45a8486425d78ff34ac Mon Sep 17 00:00:00 2001 From: SynPDX <30612189+SynPDX@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:05:28 -0700 Subject: [PATCH 26/64] fix(housing): register doors, and stop crashing on client component sheets (#2557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Players could not place **any door** while customizing a house, and placing other pieces could disconnect them outright. Staff saw neither problem: `HouseFoundation.Designer_Build` only enforces `ValidPiece` below `GameMaster`. Original report and diagnosis by @SynPDX. ## Root cause 1 — no door is ever registered The retail client's `doors.txt` separates its header rows with lines of **bare tabs** (it is the only sheet that does): ``` intint...string <-- 10 tabs, not an empty line CategoryPiece1...FeatureMaskComment 016571659... ``` `Spreadsheet.ReadLine` skipped a line only when `line.Length > 0`. A 10-tab line has length 10, so it was returned as the **names row** — every column ended up named `""`, `GetColumnID("Piece1")` and friends returned `-1`, and not one of the 230 door graphics was registered. Unregistered item IDs keep the `-1` sentinel, and `CheckValidity` rejects those, so `ValidPiece` refused every door. ClassicUO skips these lines (`string.IsNullOrWhiteSpace` in `HouseCustomizationManager.ParseFile`), which is why the client happily offers doors the server then rejects. Measured against a retail 7.0.x `doors.txt` using the shipped `Spreadsheet`: | | `FeatureMask` column | door graphics registered | |---|---|---| | before | `-1` | **0** | | after | `9` | **230** | ## Root cause 2 — `IndexOutOfRangeException` out of the packet handler Every sheet ends in a cosmetic `Comment` column that ModernUO never reads, and client sheets write an empty comment as a plain newline with no trailing tab. `Split('\t')` then returns one field fewer than the header declares, and the parser indexed past the end: ``` System.IndexOutOfRangeException: Index was outside the bounds of the array. at Server.Multis.Spreadsheet..ctor(String path) at Server.Multis.ComponentVerification.LoadSpreadsheet(...) at Server.Multis.ComponentVerification.IsItemValid(Int32 itemID) at Server.Multis.HouseFoundation.ValidPiece(Int32 itemID, Boolean roof) at Server.Multis.HouseFoundation.Designer_Build(NetState state, ...) ``` The client's own parser only requires the columns up to `FeatureMask` — ClassicUO's `CustomHouseMisc.Parse` guards on `scanf.Length >= 12` for a 13-column `misc.txt` — so such a row is valid data listing real pieces. Missing trailing fields are now treated as empty rather than dropping the row, which would unregister every piece the row lists and reproduce the door symptom. `EnsureLoaded` also set `_loaded` before loading, so once the throw escaped, an all `-1` table stayed cached and rejected everything for players from then on — the same player-visible symptom as #2500. ## Also made explicit rather than accidental - **Named the table sentinels.** `NotAComponent` (-1) is the anti-cheat guard and the initial state; `NoFeatureRequired` (0) is a piece with no expansion gate — how `walls.txt` encodes pre-AOS base pieces and what `housing.bin` collapses to under `HousingTierMask` (#2500). - **A sheet with no `FeatureMask` column is refused and logged.** `GetInt32` on a missing column returns 0 = `NoFeatureRequired`, which would have silently marked every piece in that sheet unconditionally placeable regardless of expansion. This was previously only harmless by accident. - **A sheet matching none of its expected tile columns is refused and logged** — that is what `doors.txt` was doing silently. Individual missing columns stay tolerated, since older sheets predate columns such as `walls.txt`'s `SecondAltWindowS`/`E`. - **Catch per sheet**, so one unreadable file no longer costs the other six. - **Header guards**: an empty file or a types-only file raised a `NullReferenceException`; a names row shorter than the types row indexed past the end. - **Fall back to the component sheets when `housing.bin` cannot be read**, instead of passing `null` into a `SpanReader`. `_loaded` is still set before loading, deliberately: this runs from the design packet handler, and retrying would re-read every sheet on each subsequent placement attempt. Sheet precedence is **unchanged** — the client's copies stay authoritative and `Data/Components` remains the fallback. ## Verification - Retail 7.0.x client `doors.txt` through the shipped `Spreadsheet`: 0 door graphics before, 230 after. - 5 new tests in `SpreadsheetTests` covering the tab separators, the omitted trailing field, per-row recovery, and both header guards. All 5 fail against `main` and pass here. - `dotnet build` clean (0 warnings, 0 errors); `UOContent.Tests` 642/642. --- .../Tests/Multis/SpreadsheetTests.cs | 114 +++++++++++++++++ .../UOContent/Multis/ComponentVerification.cs | 117 ++++++++++++++++-- 2 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs diff --git a/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs b/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs new file mode 100644 index 000000000..3be9d9a0d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using Server.Multis; +using Xunit; + +namespace UOContent.Tests; + +public class SpreadsheetTests : IDisposable +{ + // misc.txt's shape: 13 columns, the last being the Comment the client never reads. + private const string Types = "int\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tstring"; + + private const string Names = + "Category\tStyle\tTID\tPiece1\tPiece2\tPiece3\tPiece4\tPiece5\tPiece6\tPiece7\tPiece8\tFeatureMask\tComment"; + + private readonly string _path = Path.Combine(Path.GetTempPath(), $"muo-sheet-{Guid.NewGuid():N}.txt"); + + public void Dispose() + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + + private Spreadsheet Write(params string[] rows) + { + var lines = new string[rows.Length + 2]; + lines[0] = Types; + lines[1] = Names; + rows.CopyTo(lines, 2); + + File.WriteAllLines(_path, lines); + return new Spreadsheet(_path); + } + + [Fact] + public void RowMissingTrailingCommentIsStillRead() + { + // An empty Comment written without a trailing tab leaves the row one field short. + var ss = Write("0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t8"); + + var record = Assert.Single(ss.Records); + + Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1"))); + Assert.Equal(29, record.GetInt32(ss.GetColumnID("Piece8"))); + Assert.Equal(8, record.GetInt32(ss.GetColumnID("FeatureMask"))); + } + + [Fact] + public void ShortRowDoesNotDropLaterRows() + { + var ss = Write( + "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0", + "0\t1\t1060057\t45\t0\t0\t0\t0\t0\t0\t0\t0\tFieldstone Arches" + ); + + Assert.Equal(2, ss.Records.Length); + Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1"))); + Assert.Equal(45, ss.Records[1].GetInt32(ss.GetColumnID("Piece1"))); + } + + [Fact] + public void TabOnlySeparatorLinesAreNotMistakenForHeaderRows() + { + // The retail client's doors.txt separates its header rows this way. + var separator = new string('\t', 12); + + File.WriteAllLines( + _path, + [ + Types, + separator, + Names, + separator, + "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways" + ] + ); + + var ss = new Spreadsheet(_path); + + Assert.Equal(3, ss.GetColumnID("Piece1")); + Assert.Equal(11, ss.GetColumnID("FeatureMask")); + + var record = Assert.Single(ss.Records); + Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1"))); + } + + [Fact] + public void MissingHeaderRowsThrowsInsteadOfNullReference() + { + File.WriteAllLines(_path, [Types]); + + Assert.Throws(() => new Spreadsheet(_path)); + } + + [Fact] + public void HeaderWithFewerNamesThanTypesIsTolerated() + { + File.WriteAllLines( + _path, + [ + Types, + "Category\tStyle\tTID\tPiece1", + "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways" + ] + ); + + var ss = new Spreadsheet(_path); + + Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1"))); + Assert.Equal(-1, ss.GetColumnID("FeatureMask")); + } +} diff --git a/Projects/UOContent/Multis/ComponentVerification.cs b/Projects/UOContent/Multis/ComponentVerification.cs index 967fed1bc..dda094aa4 100644 --- a/Projects/UOContent/Multis/ComponentVerification.cs +++ b/Projects/UOContent/Multis/ComponentVerification.cs @@ -4,11 +4,14 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; using Server.Compression; +using Server.Logging; namespace Server.Multis; public static class ComponentVerification { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ComponentVerification)); + private static int[] _itemTable; private static int[] _multiTable; private static bool _loaded; @@ -20,6 +23,15 @@ public static class ComponentVerification // encodes them) while AOS/SE/ML/... line up unchanged. private const int HousingTierMask = (int)HousingFlags.HousingEJ; + // Table sentinels. Slots start as NotAComponent, which CheckValidity rejects, so a piece that + // never gets registered can never be placed. NoFeatureRequired is a piece with no expansion + // requirement: walls.txt encodes pre-AOS base pieces as 0, housing.bin collapses to 0 under + // HousingTierMask. + private const int NotAComponent = -1; + private const int NoFeatureRequired = 0; + + private const string FeatureMaskColumn = "FeatureMask"; + public static bool IsItemValid(int itemID) { EnsureLoaded(); @@ -33,7 +45,8 @@ public static class ComponentVerification } private static bool CheckValidity(int val) => - val != -1 && (val == 0 || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0); + val != NotAComponent && + (val == NoFeatureRequired || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0); private static void EnsureLoaded() { @@ -42,22 +55,50 @@ public static class ComponentVerification return; } + // Set before loading: this runs from the design packet handler, so a bad file must not + // re-read every sheet on each later placement attempt. Sheets below fail independently. _loaded = true; _itemTable = CreateTable(TileData.MaxItemValue); _multiTable = CreateTable(0x4000); var housingPath = MultiData.HousingUOPPath; - if (housingPath != null) + if (housingPath != null && TryLoadFromHousingBin(housingPath)) { - var entry = MultiData.HousingEntry; - LoadFromHousingBin(ReadUOPEntry(housingPath, entry)); return; } LoadFromTxtFiles(); } + private static bool TryLoadFromHousingBin(string path) + { + try + { + var data = ReadUOPEntry(path, MultiData.HousingEntry); + if (data != null) + { + LoadFromHousingBin(data); + return true; + } + + logger.Warning( + "Could not decompress housing.bin from {Path}. Falling back to the component sheets", + path + ); + } + catch (Exception ex) + { + logger.Warning( + ex, + "Failed to read housing.bin from {Path}. Falling back to the component sheets", + path + ); + } + + return false; + } + private static byte[] ReadUOPEntry(string path, UOPEntry entry) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); @@ -223,16 +264,55 @@ public static class ComponentVerification return; } - var ss = new Spreadsheet(path); + Spreadsheet ss; + try + { + ss = new Spreadsheet(path); + } + catch (Exception ex) + { + // One unreadable sheet must not take the others down with it. + logger.Error(ex, "Could not read house components from {Path}", path); + return; + } + + // GetInt32 on a missing column yields NoFeatureRequired, which would register every piece in + // the sheet as unconditionally placeable. Refuse the sheet instead. + var featureCID = ss.GetColumnID(FeatureMaskColumn); + if (featureCID < 0) + { + logger.Error( + "House component sheet {Path} has no {Column} column. Its pieces will not be registered", + path, + FeatureMaskColumn + ); + return; + } + + // An individual missing column is expected - older sheets predate walls.txt's + // SecondAltWindowS/E - but a sheet matching none of them is not the sheet we expect. var tileCIDs = new int[tileColumns.Length]; + var matchedColumns = 0; for (var i = 0; i < tileColumns.Length; ++i) { tileCIDs[i] = ss.GetColumnID(tileColumns[i]); + + if (tileCIDs[i] >= 0) + { + matchedColumns++; + } } - var featureCID = ss.GetColumnID("FeatureMask"); + if (matchedColumns == 0) + { + logger.Error( + "House component sheet {Path} has none of its expected tile columns. Its pieces will not be registered", + path + ); + return; + } for (var i = 0; i < ss.Records.Length; ++i) { @@ -260,7 +340,7 @@ public static class ComponentVerification for (var i = 0; i < table.Length; ++i) { - table[i] = -1; + table[i] = NotAComponent; } return table; @@ -277,11 +357,18 @@ public class Spreadsheet var types = ReadLine(ip); var names = ReadLine(ip); + if (types == null || names == null) + { + throw new InvalidDataException($"House component sheet '{path}' is missing its header rows."); + } + m_Columns = new ColumnInfo[types.Length]; for (var i = 0; i < m_Columns.Length; ++i) { - m_Columns[i] = new ColumnInfo(i, types[i], names[i]); + // A names row shorter than the types row leaves the extras unnamed, so nothing resolves + // to them. + m_Columns[i] = new ColumnInfo(i, types[i], i < names.Length ? names[i] : ""); } var records = new List(); @@ -294,10 +381,15 @@ public class Spreadsheet { var ci = m_Columns[i]; + // Client sheets write an empty trailing Comment as a plain newline, leaving the row + // one field short. The client only requires the columns up to FeatureMask, so treat + // the missing field as empty rather than dropping a row that lists real pieces. + var value = ci.m_DataIndex < values.Length ? values[ci.m_DataIndex] : null; + data[i] = ci.m_Type switch { - "int" => Utility.ToInt32(values[ci.m_DataIndex]), - "string" => values[ci.m_DataIndex], + "int" => Utility.ToInt32(value), + "string" => value, _ => data[i] }; } @@ -327,7 +419,10 @@ public class Spreadsheet { while (ip.ReadLine() is { } line) { - if (line.Length > 0) + // Whitespace-only, not merely empty: the retail client's doors.txt separates its header + // rows with lines of bare tabs, and accepting one as the names row leaves every column + // unnamed, so no door resolves. The client skips them the same way. + if (!string.IsNullOrWhiteSpace(line)) { return line.Split('\t'); } From 6d8107777207fd6bf8887d5a8f6de5c15c1dbdec Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:11:37 -0700 Subject: [PATCH 27/64] perf(network): consume IORingGroup 1.0.9 to drop the per-iteration 6 KiB memset (#2558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `IORingGroup`'s `WindowsManagedRIOGroup.DequeueRioCompletions` stackallocs `RIORESULT[256]` (6144 bytes) and runs **once per game-loop iteration** — `NetState.Slice` → `RingSocketManager.ProcessCompletions` → `PeekCompletions` → `DequeueRioCompletions`. The 1.0.8 package was compiled with the `.locals init` IL flag set, so every one of those calls memset the full 6 KiB before `RIODequeueCompletion` overwrote the entries it actually filled. An EventPipe profile of a near-idle shard (3 vCPU VPS, world saves off, one player logging in and moving around) put `System.Buffer.ZeroMemoryInternal` — called directly from `DequeueRioCompletions` — at **~2.8% of main-thread samples**, and it was the dominant frame in several 60–127 ms game-loop stalls. ## Why our existing attribute didn't cover it `Projects/Server/Module.cs` and `Projects/UOContent/Module.cs` already declare `[module: SkipLocalsInit]`. That attribute is a **compile-time** directive: it clears the flag in the IL of the assembly being compiled, and does not cross assembly boundaries. It never applied to the package. Verified by reading the shipped IL (`MethodBodyBlock.LocalVariablesInitialized`): | Assembly | attribute | methods with `.locals init` | |---|---|---| | `Server.dll` | present | 0 of 5439 | | `IORingGroup` 1.0.8 | **absent** | **158** | | `IORingGroup` 1.0.9 | present | **0 of 389** | ## Testing Built and tested against the locally-built 1.0.9 package (temporary local feed, not committed): - `dotnet build -c Release` — **0 warnings, 0 errors** - `Server.Tests` — **810 passed, 0 failed** - `UOContent.Tests` — **637 passed, 0 failed** - Confirmed the `IORingGroup.dll` deployed to `Distribution/` is the fixed build (0 of 389 methods zeroing) Only the `` version changes; no source changes on this side. --- Projects/Server/Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 8953efcba..b022c584c 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,7 +34,7 @@ - + From 246f077778514ce2b6f8c1a7176871c89f1ad259 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:32:25 -0700 Subject: [PATCH 28/64] chore: drop the liburing prerequisite, which was never used (#2560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `IORingGroup` issues io_uring syscalls directly rather than linking `liburing`, so the package has never been needed — but we ask operators to install it in the README, install it in CI, and check for it in `build-tool`. Verified against the **shipped** `IORingGroup` 1.0.9 assembly, not just the source: | Symbol | Occurrences in `IORingGroup.dll` | |---|---| | `libc`, `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll`, `ws2_32.dll` | present | | `liburing` | **0** | | `io_uring_queue_init` — liburing's entry point | **0** | | `io_uring_setup` — the raw syscall | 1 | If it linked liburing it would call `io_uring_queue_init` / `io_uring_submit`. It calls neither. ## What changes Nine lines across three files, removing `liburing-dev` / `liburing-devel` from: - `README.md` — both the dnf and apt prerequisite blocks - `.github/workflows/build-test.yml` — both install steps - `Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs` — the cross-compile target text, the apt and dnf package lists, and the `ldconfig` fallback map Nothing else is touched. `zstd` and the `-dev` packages are a separate discussion and a separate PR. ## Risk None to the build. `liburing` was only ever installed, never linked or loaded — removing it cannot change resolution behaviour. `build-tool` builds clean. This was found while investigating why Linux requires `-dev` packages at all; that fix lives in the binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13) and lands separately once those publish. This piece is independent and unblocked, hence its own PR. --- .github/workflows/build-test.yml | 4 ++-- .../BuildTool/Prerequisites/NativeLibraryChecker.cs | 11 +++++------ README.md | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index e7bf70d70..92684562b 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -125,10 +125,10 @@ jobs: dnf install -y epel-release if: ${{ matrix.epel }} - name: Install Prerequisites using dnf - run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel + run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel if: ${{ matrix.packageManager == 'dnf' }} - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata liburing-dev + run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata if: ${{ matrix.packageManager == 'apt' }} - uses: actions/checkout@v7 with: diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 5334e4a6a..4f84aa373 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -31,8 +31,8 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev", - "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel", + "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev", + "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -189,7 +189,7 @@ public static class NativeLibraryChecker private static List CheckLinuxApt() { var results = new List(); - var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev", "liburing-dev" }; + var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev" }; var missing = new List(); foreach (var package in packages) @@ -228,7 +228,7 @@ public static class NativeLibraryChecker private static List CheckLinuxDnf(PlatformInfo platform) { var results = new List(); - var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" }; + var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel" }; var missing = new List(); foreach (var package in packages) @@ -291,8 +291,7 @@ public static class NativeLibraryChecker ["libicu"] = "libicuuc", ["libdeflate"] = "libdeflate", ["zstd"] = "libzstd", - ["libargon2"] = "libargon2", - ["liburing"] = "liburing" + ["libargon2"] = "libargon2" }; foreach (var (name, soName) in libraries) diff --git a/README.md b/README.md index 5bc8e7985..b61204743 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,13 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel +dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev +apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev ``` ## OSX Requirements From 23dc6649a031e84335483552f2dd665588e5575d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:03:08 -0700 Subject: [PATCH 29/64] fix: Require only runtime packages on Linux, and check ICU and tzdata the way the runtime does (#2561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why ModernUO mandated `-dev` packages on production servers for exactly one reason: `DllImport` never asks for a versioned SONAME, so `libdeflate.so.0` and `libargon2.so.1` sitting in `/usr/lib` went unfound, and the `-dev` package's unversioned symlink was the only thing making resolution work. The `-dev` packages ship no library of their own — operators were installing headers and a static lib on machines that compile nothing. Fixed in the binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13), so this picks them up and stops asking. ``` LibDeflate.Bindings 1.0.3 -> 1.0.4 Argon2.Bindings 1.17.0 -> 1.19.0 ``` ## zstd is dropped too, on every platform ZstdNet bundles `libzstd` for `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64` and win, and nothing shells out to the CLI. Verified: the 15 `ManagedArchive` round-trip tests pass in a container with no `zstd` package installed and `which zstd` empty. Removed from the README, the macOS `brew install`, and CI — so the macOS runners now prove it rather than us assuming it. ## NativeLibraryChecker asks a different question It asked *"is package X installed"* via `dpkg -l` / `rpm -q`. That is what forced `-dev`, and no hardcoded name works for ICU anyway — its apt package is release-specific (`libicu70` on Ubuntu 22.04, `libicu76` on Debian 13). It now asks *"will the loader find this"*: `NativeLibrary.TryLoad` on the unversioned name, then `libfoo.so.N` descending through the range the runtime accepts. It deliberately does not consult a package database or `ldconfig -p`. Both answer a different question than "will `dlopen` succeed" — see the ICU section below for how that bit. ## What was wrong with the ICU check `libicuuc` was **inherited, not derived**. It came from translating the old package-name check into a library probe, without establishing which library that should be. Reviewing it turned up three defects, all of which could report ICU present on a host where the runtime then refuses to start: - **`libicui18n` was never probed.** The only ICU names in `libSystem.Globalization.Native.so` are `libicuuc` and `libicui18n`. `libicudata` arrives as a dependency of `libicuuc`, and `libicuio`/`libicutu`/`libicutest` are never referenced — so that is the complete list, and both are checked now. - **No version floor.** The runtime's `MinICUVersion` is 60, but the probe accepted down to `.so.0`. RHEL/CentOS 7 ships ICU 50, which passed and then aborted at startup. - **The `ldconfig` fast path bypassed the range.** A cache line for `libicuuc.so.50` still matches a `libicuuc.so` prefix test, so the floor was unenforceable through it. It also trusts a stale cache — observed reporting a deleted `libdeflate` as present. Removed in favour of asking the loader directly, which reads the same cache but answers the real question, and which also deletes the musl special-case (`ldconfig -p` exits 0 on musl while producing nothing usable). Worth knowing when this goes wrong in the field: **missing ICU does not throw, it `FailFast`s** — SIGABRT, exit 134, uncatchable. The process starts cleanly and dies later at whatever line first touches a culture, so the stack rarely implicates ICU. ## tzdata is a separate prerequisite, and nothing was checking it The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads `/usr/share/zoneinfo`. It is data rather than a library, so no loader probe finds it, and slim container images routinely omit it. Without it every lookup except `UTC` throws `TimeZoneNotFoundException` and `GetSystemTimeZones()` returns 1 entry instead of ~419. There is no per-zone packaging to opt into — it is ~2 MB for the whole set. The one split that does exist is a trap rather than an optimization: Debian 12 and Ubuntu 24.04 move the legacy aliases into `tzdata-legacy`, so plain `tzdata` has `America/New_York` and `EST5EDT` but is **missing `US/Eastern` and `Asia/Calcutta`**. A shard configured with a legacy alias throws even though tzdata is installed. Documented, with both fixes. ## Why `InvariantGlobalization` stays false Dropping ICU entirely by turning on invariant mode looks tempting and is not safe. Because `Directory.Build.props` also sets `PredefinedCulturesOnly=false`, invariant mode does **not** throw `CultureNotFoundException` — it silently hands back invariant data. Measured on .NET 10: | Behaviour | With ICU | Invariant mode | |---|---|---| | `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data | | de-DE decimal separator | `,` | `.` | | `1234.5` as de-DE | `1.234,5` | `1,234.5` | | `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) | | sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` | | `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` | | UTF-8 round-trip of non-ASCII | unaffected | unaffected | Number parsing and formatting produce wrong values with no error, and culture-sensitive sort order silently becomes ordinal. Encoding is not the mechanism — UTF-8 round-trips fine either way. ## Documentation The rationale now lives in `dev-docs/platform-prerequisites.md` rather than in comments, so it is discoverable without reading the build tool: what each dependency is for, what breaks without it, per-distro package names, the ICU floor, the `tzdata-legacy` split, and why the check asks the loader instead of the package manager. README drops `libicu-dev`. Matching the runtime package by pattern (`'^libicu[0-9]+$'`) is version-independent without pulling in headers, so **no `-dev` package is required on any supported distribution** — which was the point of the whole change. ## CI now proves the claim instead of contradicting it The dnf job already installed runtime packages only. The apt job installed `libicu-dev`, which ships the unversioned `libicuuc.so` symlink — so every probe succeeded on the first attempt and the versioned-SONAME fallback this PR depends on was never exercised. Switched to the pattern match, verified to resolve exactly one package on jammy (70), bookworm (72), noble (74) and trixie (76). Added an assertion that the unversioned symlinks are absent. Without it the suite silently stops testing anything the moment a base image starts shipping one. Verified against all eight matrix distributions — none ship them — and confirmed the step fails as intended when a symlink is planted. ## Audit of every other native entry point Checked whether anything else has the same hazard. It does not: | Import | Verdict | |---|---| | `ws2_32.dll` — `SocketHelper` | Always present on Windows | | `libc` — `SocketHelper` | **Verified safe**, see below | | ZstdNet → `libzstd` | Bundled for every RID | | IORingGroup | No native library; raw syscalls | | ICU | Loaded by the .NET runtime itself, which probes versioned suffixes | `libc` deserved a hard look, because `libc.so` *is* a `libc6-dev` linker script while the real library is `libc.so.6` — the same shape as the bug being fixed. It is not affected. Measured in a container with no `libc6-dev`: ``` /usr/lib/x86_64-linux-gnu/libc.so ABSENT /lib/x86_64-linux-gnu/libc.so.6 present TryLoad("libc") LOADED <- resolves where "libdeflate" would not TryLoad("libc.so") not found getpid() -> DllImport("libc") WORKS ``` Confirmed on Alpine/musl as well. No code in this repo registers a `DllImportResolver`, and nothing else P/Invokes. ## `--check-prereqs` New flag. `Program.cs` only ran the SDK check in non-interactive mode — `NativeLibraryChecker` was reachable only through the Spectre-driven guided flow, so there was no way to verify a deployment target from a script or a container. It is what made the container verification below possible, and it prints the exact ICU package for the running release via `apt-cache`. It renders through the same `PrerequisiteChecker` the guided menu uses, rather than a second hand-rolled table that could drift from it. Spectre drops ANSI styling on its own when stdout is not a terminal, so redirected output stays clean; the console width is widened in that case so the install hints, which are shell commands meant to be copied, do not gain a newline mid-command. ``` ╭───────────────────────────╮ │ Checking native libraries │ ╰───────────────────────────╯ ✔ libicuuc (Found) ✔ libicui18n (Found) ❌ libdeflate (Not found) ❌ tzdata (Not found — every zone except UTC will throw) ⚠️ Install the missing dependencies. The -dev/-devel packages are not required: sudo apt-get install -y libicu74 libdeflate0 tzdata ``` Exit code carries the machine-readable half: 0 when everything resolves, 1 when anything is missing. ## Verification Against 1.0.4 and 1.19.0: build plus **810 Server.Tests and 642 UOContent.Tests**, on Windows and on Linux with **only** `libdeflate0` and `libargon2-1` installed — with the absence of the unversioned symlink asserted first so the run could not pass for the wrong reason. `--check-prereqs` verified in containers on Debian and Alpine across every state that matters: all present, each dependency removed individually, tzdata removed, a deliberately stale `ldconfig` cache, and ICU downgraded to `.so.50` to confirm the floor rejects it. Package resolution and the absence of unversioned symlinks checked on all eight CI distributions. --- .github/workflows/build-test.yml | 30 ++- CLAUDE.md | 1 + Projects/BuildTool/BuildOptions.cs | 7 + .../Prerequisites/NativeLibraryChecker.cs | 241 +++++++++++------- Projects/BuildTool/Program.cs | 23 ++ Projects/Server/Server.csproj | 2 +- Projects/UOContent/UOContent.csproj | 4 +- README.md | 19 +- dev-docs/platform-prerequisites.md | 159 ++++++++++++ 9 files changed, 388 insertions(+), 98 deletions(-) create mode 100644 dev-docs/platform-prerequisites.md diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 92684562b..eb74c39a4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -46,7 +46,7 @@ jobs: - name: Install Prerequisites run: | brew update - brew install icu4c libdeflate zstd argon2 + brew install icu4c libdeflate argon2 - name: Set Library Path run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV - name: Build @@ -124,12 +124,36 @@ jobs: dnf config-manager --set-enabled crb dnf install -y epel-release if: ${{ matrix.epel }} + # Runtime packages only, deliberately. Installing the -dev packages here would add the + # unversioned .so symlink and mask the very thing the binding packages now probe for, so a + # regression in versioned-SONAME resolution would sail through CI. - name: Install Prerequisites using dnf - run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel + run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata if: ${{ matrix.packageManager == 'dnf' }} + # ICU's runtime package carries the ABI version in its name (libicu70 on jammy, libicu76 on + # trixie) and has no stable alias, so match it by pattern. libicu-dev was the old way to stay + # version-independent, but it drags in the unversioned symlink and defeats the check below. - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata + run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata if: ${{ matrix.packageManager == 'apt' }} + # Versioned-SONAME resolution is only under test while the unversioned symlink is absent. If a + # base image or a package ever starts shipping it, every probe would succeed on the first try + # and a regression in the fallback would sail through CI, so fail loudly instead of silently + # testing nothing. + - name: Assert the unversioned .so symlinks are absent + run: | + found="" + for lib in libicuuc libicui18n libdeflate libargon2; do + hit=$(ls /usr/lib/*/"$lib".so /usr/lib64/"$lib".so 2>/dev/null || true) + if [ -n "$hit" ]; then + found="$found $hit" + fi + done + if [ -n "$found" ]; then + echo "::error::Unversioned symlinks present, so CI is no longer exercising versioned SONAME resolution:$found" + exit 1 + fi + echo "No unversioned symlinks present; versioned SONAME resolution is under test." - uses: actions/checkout@v7 with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. diff --git a/CLAUDE.md b/CLAUDE.md index 7f24661a4..924255249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | +| Platform prerequisites (ICU, tzdata, native libs per distro) | `dev-docs/platform-prerequisites.md` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | | IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs index 9aea5010d..9e1d56388 100644 --- a/Projects/BuildTool/BuildOptions.cs +++ b/Projects/BuildTool/BuildOptions.cs @@ -14,4 +14,11 @@ public sealed class BuildOptions public string? Arch { get; set; } public bool SkipPrereqs { get; set; } public bool Interactive { get; set; } + + /// + /// Report the native library prerequisites and exit. The interactive flow is the only other + /// path that runs these checks, so without this there is no way to verify a deployment target + /// from a script or a container. + /// + public bool CheckPrereqsOnly { get; set; } } diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 4f84aa373..95c521c24 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using BuildTool.Platform; using BuildTool.Publishing; @@ -31,8 +32,10 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev", - "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel", + "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata", + " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", + " (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)", + "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -176,82 +179,59 @@ public static class NativeLibraryChecker return results; } + /// + /// Native libraries the server needs from the system on Linux, and the SONAME range to accept + /// for each. Rationale and per-distro package names: dev-docs/platform-prerequisites.md. + /// + private static readonly (string Name, int MinSoVersion, int MaxSoVersion)[] _linuxLibraries = + [ + ("libicuuc", 60, 120), + ("libicui18n", 60, 120), + ("libdeflate", 0, 9), + ("libargon2", 0, 9) + ]; + private static List CheckLinux(PlatformInfo platform) - { - return platform.PackageManager switch - { - PackageManager.Apt => CheckLinuxApt(), - PackageManager.Dnf => CheckLinuxDnf(platform), - _ => CheckLinuxGeneric(platform) - }; - } - - private static List CheckLinuxApt() { var results = new List(); - var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev" }; var missing = new List(); - foreach (var package in packages) + foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries) { - var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}"); - var installed = result.Success && result.StandardOutput.Contains("ii"); + var found = CanLoad(name, minSoVersion, maxSoVersion); - if (!installed) + if (!found) { - missing.Add(package); + missing.Add(name); } results.Add(new PrerequisiteResult { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" + Name = name, + Passed = found, + Details = found ? "Found" : "Not found" }); } - if (missing.Count > 0) + var hasTimeZoneData = HasTimeZoneData(); + if (!hasTimeZoneData) { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}" - }); + missing.Add("tzdata"); } - return results; - } - - private static List CheckLinuxDnf(PlatformInfo platform) - { - var results = new List(); - var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel" }; - var missing = new List(); - - foreach (var package in packages) + results.Add(new PrerequisiteResult { - var result = ProcessRunner.RunCaptured("rpm", $"-q {package}"); - var installed = result.Success; + Name = "tzdata", + Passed = hasTimeZoneData, + Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw" + }); - if (!installed) - { - missing.Add(package); - } - - results.Add(new PrerequisiteResult - { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" - }); + if (missing.Count == 0) + { + return results; } - // Check if this is CentOS (needs EPEL) - var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true; - if (isCentOs && missing.Count > 0) + if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true) { results.Add(new PrerequisiteResult { @@ -263,48 +243,131 @@ public static class NativeLibraryChecker }); } - if (missing.Count > 0) + results.Add(new PrerequisiteResult { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}" - }); - } + Name = "Install all missing", + Passed = false, + IsWarning = true, + Details = "Install the missing dependencies. The -dev/-devel packages are not required:", + InstallCommand = BuildInstallCommand(platform, missing) + }); return results; } - private static List CheckLinuxGeneric(PlatformInfo platform) + /// + /// tzdata is data, not a library, so no loader probe finds it. Asking the runtime rather than + /// stat'ing a path keeps TZDIR honoured, and the count is still accurate under + /// InvariantGlobalization, which this tool runs with — only display names degrade there. + /// + private static bool HasTimeZoneData() { - var results = new List(); - - // Use ldconfig to check for shared libraries - var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); - var ldOutput = ldResult.Success ? ldResult.StandardOutput : ""; - - var libraries = new Dictionary + try { - ["libicu"] = "libicuuc", - ["libdeflate"] = "libdeflate", - ["zstd"] = "libzstd", - ["libargon2"] = "libargon2" - }; - - foreach (var (name, soName) in libraries) + return TimeZoneInfo.GetSystemTimeZones().Count > 1; + } + catch { - var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase); - results.Add(new PrerequisiteResult - { - Name = name, - Passed = found, - Details = found ? "Found" : "Not found — install using your package manager" - }); + return false; + } + } + + /// + /// Asks the loader directly rather than querying a package database or scanning ldconfig's + /// cache, both of which answer a different question and can disagree with what dlopen will do. + /// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N + /// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and + /// /etc/ld.so.conf.d still apply. + /// + private static bool CanLoad(string library, int minSoVersion, int maxSoVersion) + { + if (TryLoadAndFree($"{library}.so")) + { + return true; } - return results; + for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--) + { + if (TryLoadAndFree($"{library}.so.{soVersion}")) + { + return true; + } + } + + return false; + } + + private static bool TryLoadAndFree(string candidate) + { + if (!NativeLibrary.TryLoad(candidate, out var handle)) + { + return false; + } + + NativeLibrary.Free(handle); + return true; + } + + private static string BuildInstallCommand(PlatformInfo platform, List missing) + { + switch (platform.PackageManager) + { + case PackageManager.Apt: + { + // Distinct because the two ICU libraries resolve to the same package, and + // ResolveAptIcuPackage shells out, so it is memoized rather than called per name. + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate0", + "libargon2" => "libargon2-1", + "tzdata" => "tzdata", + _ => _aptIcuPackage ??= ResolveAptIcuPackage() + } + ).Distinct(); + + return $"sudo apt-get install -y {string.Join(' ', packages)}"; + } + case PackageManager.Dnf: + { + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate", + "libargon2" => "libargon2", + "tzdata" => "tzdata", + _ => "libicu" + } + ).Distinct(); + + return $"sudo dnf install -y {string.Join(' ', packages)}"; + } + default: + return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}"; + } + } + + private static string _aptIcuPackage; + + /// + /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask + /// apt which one this release actually ships instead of printing a name that rots. + /// + private static string ResolveAptIcuPackage() + { + var result = ProcessRunner.RunCaptured("apt-cache", "search --names-only ^libicu[0-9]+$"); + if (!result.Success) + { + return "libicu"; + } + + var best = result.StandardOutput + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(' ', 2)[0].Trim()) + .Where(name => name.StartsWith("libicu", StringComparison.Ordinal)) + .OrderBy(name => int.TryParse(name.AsSpan(6), out var version) ? version : 0) + .LastOrDefault(); + + return best ?? "libicu"; } } diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs index 3b36066da..915839bbf 100644 --- a/Projects/BuildTool/Program.cs +++ b/Projects/BuildTool/Program.cs @@ -4,6 +4,7 @@ using BuildTool.Interactive; using BuildTool.Platform; using BuildTool.Prerequisites; using BuildTool.Publishing; +using Spectre.Console; Console.OutputEncoding = Encoding.UTF8; @@ -36,6 +37,22 @@ options.Os ??= detectedPlatform.OsRid; options.Arch ??= detectedPlatform.ArchRid; var rid = $"{options.Os}-{options.Arch}"; +if (options.CheckPrereqsOnly) +{ + // Same renderer the guided menu uses, so the two cannot drift. Spectre drops ANSI styling by + // itself when stdout is not a terminal, which is the case this flag exists for, but it also + // falls back to an 80 column width and folds anything longer. The install hints we print are + // shell commands — the CentOS one is 95 characters — and a fold puts a newline in the middle of + // a command that someone is meant to copy. Widen the profile so they stay on one line. + if (Console.IsOutputRedirected) + { + AnsiConsole.Profile.Width = 200; + } + + // Exit code is the machine-readable half: 0 when everything resolves, 1 when anything is missing. + return PrerequisiteChecker.CheckNativeLibraries(detectedPlatform, interactive: false) ? 0 : 1; +} + // Run prerequisite checks unless skipped if (!options.SkipPrereqs) { @@ -108,6 +125,12 @@ static BuildOptions ParseArguments(string[] args) hasNamedArgs = true; break; } + case "--check-prereqs": + { + options.CheckPrereqsOnly = true; + hasNamedArgs = true; + break; + } case "--interactive": { options.Interactive = true; diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index b022c584c..8ad6a610b 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -36,7 +36,7 @@ - + diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 4f230902d..bf288e629 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,12 +40,12 @@ false - + - + diff --git a/README.md b/README.md index b61204743..2321fe8e3 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,31 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel +dnf install -y findutils libicu libdeflate libargon2 tzdata ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev +# The ICU runtime package carries the ABI version in its name (libicu74, libicu76, …) and has no +# stable alias, so match it by pattern rather than pinning a release-specific name. +apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata ``` +Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. Run +`./build-tool --check-prereqs` to check the current machine and print the exact packages your +release needs. + +`zstd` is not listed because ZstdNet bundles `libzstd` for every platform, and `liburing` is not +listed because IORingGroup issues `io_uring` syscalls directly. + +If the shard's configured time zone is a legacy alias such as `US/Eastern`, Debian 12 and Ubuntu +24.04 also need `tzdata-legacy`. See [Platform Prerequisites](dev-docs/platform-prerequisites.md) +for what each dependency is for and what breaks without it. + ## OSX Requirements ```shell -brew install icu4c libdeflate zstd argon2 +brew install icu4c libdeflate argon2 ``` ## Running the Server diff --git a/dev-docs/platform-prerequisites.md b/dev-docs/platform-prerequisites.md new file mode 100644 index 000000000..3e76d3b47 --- /dev/null +++ b/dev-docs/platform-prerequisites.md @@ -0,0 +1,159 @@ +# Platform Prerequisites + +OS-level dependencies ModernUO needs at runtime, why each one is required, and what breaks without +it. This page is about software packages, not hardware sizing. + +Run `./build-tool --check-prereqs` from the repository root to check the current machine. It prints +the exact install command for the detected distribution. + +## What is required + +| Dependency | Platform | Why | +|---|---|---| +| .NET 10 Runtime | all | — | +| ICU (`libicuuc`, `libicui18n`) | Linux, macOS | The runtime refuses to start without it; see below | +| tzdata | Linux | Time zone lookups; see below | +| `libdeflate` | all | `LibDeflate.Bindings` | +| `libargon2` | all | `Argon2.Bindings` (password hashing) | +| VC++ Redistributable v14 | Windows | Native bindings | + +Not required, despite appearances: + +- **zstd** — `ZstdNet` bundles `libzstd` for every RID. +- **liburing** — `IORingGroup` issues `io_uring` syscalls directly. It imports only `libc`, + `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll` and `ws2_32.dll`. +- **`-dev` / `-devel` packages** — see "Runtime packages only" below. + +## Install + +```sh +# Debian / Ubuntu (ICU has no stable package alias, so match it by pattern) +sudo apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata + +# Fedora / RHEL +sudo dnf install -y libdeflate libargon2 libicu tzdata + +# Alpine +apk add --no-cache libdeflate argon2-libs icu-libs tzdata + +# macOS +brew install icu4c libdeflate argon2 +``` + +CentOS additionally needs EPEL and CRB: + +```sh +sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb +``` + +## Runtime packages only + +Only the runtime packages are needed. The `-dev`/`-devel` packages are **not** required. + +They used to be, because .NET's `DllImport` probing looks for the unversioned `libfoo.so`, and on +Linux that bare symlink ships only in the development package. The runtime package ships the +versioned SONAME (`libdeflate.so.0`, `libargon2.so.1`). The binding packages now probe the versioned +names as well, so the runtime package is sufficient. + +Anything still documenting `libicu-dev` or `libdeflate-dev` as a requirement is out of date. + +## ICU + +`Directory.Build.props` sets `InvariantGlobalization=false`, so ICU is mandatory. Without it the +runtime does **not** throw — it `FailFast`s: + +``` +Couldn't find a valid ICU package installed on the system. Please install libicu (or icu-libs) +using your package manager and try again. +``` + +That is `SIGABRT` (exit 134) and it cannot be caught. Note the process **starts cleanly and aborts +later**, at whatever line first touches a culture, so the crash rarely points at the cause. + +### Why invariant mode is not an option + +`InvariantGlobalization=true` would remove the ICU dependency, but it changes behaviour in ways that +corrupt data silently. Measured on .NET 10 with the repository's settings: + +| Behaviour | With ICU | Invariant mode | +|---|---|---| +| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data | +| de-DE decimal separator | `,` | `.` | +| `1234.5` as de-DE | `1.234,5` | `1,234.5` | +| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) | +| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` | +| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` | +| UTF-8 round-trip of non-ASCII | unaffected | unaffected | + +The dangerous row is the first. Because `Directory.Build.props` also sets +`PredefinedCulturesOnly=false`, constructing a culture in invariant mode **succeeds** instead of +throwing `CultureNotFoundException`, and hands back an object populated with invariant data. Number +parsing and formatting then produce wrong values with no error, and culture-sensitive sort order +silently becomes ordinal. + +Encoding is not affected — UTF-8 round-trips correctly in both modes. + +### Version floor + +The runtime accepts `libicuuc.so.60` and above (`MinICUVersion` in `pal_icushim.c`). The prerequisite +checker enforces the same floor, so a host carrying only an older ICU is reported missing rather than +passing and then aborting at startup. RHEL/CentOS 7 ships ICU 50 and is affected. + +ICU tracks its own release train, so the SONAME digit varies widely by distribution — `.so.74` on +Ubuntu 24.04, `.so.76` on Alpine, `.so.77` on Fedora, `.so.78` on openSUSE. There is no stable +package alias on Debian and Ubuntu, which is why the checker resolves the name via `apt-cache` +instead of hardcoding one. + +Only `libicuuc` and `libicui18n` are used; those are the two names +`libSystem.Globalization.Native.so` loads. `libicudata` arrives as a dependency of `libicuuc`, and +`libicuio`/`libicutu`/`libicutest` are never referenced. Every distribution ships all of them in a +single package, so installing ICU at all satisfies both. + +## tzdata + +The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads +`/usr/share/zoneinfo` on Linux. This is separate from ICU: it is data, not a library, so no loader +probe finds it, and slim container images routinely omit it. + +Without tzdata every lookup except `UTC` throws: + +``` +TimeZoneNotFoundException: The time zone ID 'America/New_York' was not found on the local computer. +``` + +`TimeZoneInfo.GetSystemTimeZones()` returns 1 entry instead of ~419, and `TimeZoneInfo.Local` falls +back to UTC. + +### There is no per-zone subset + +Distributions do not package individual zones — it is one `tzdata` package, about 2 MB installed for +the full set. Subsetting is not worth pursuing. + +The one split that does exist is **`tzdata-legacy`** on Debian 12 and Ubuntu 24.04, which carries the +deprecated aliases. With plain `tzdata` alone: + +| Zone ID | `tzdata` | `tzdata-legacy` | +|---|---|---| +| `America/New_York` | present | — | +| `Europe/Kyiv` | present | — | +| `EST5EDT` | present | — | +| `US/Eastern` | **missing** | present | +| `Asia/Calcutta` | **missing** | present | + +So a shard configured with a legacy alias such as `US/Eastern` throws on a current Debian or Ubuntu +even though tzdata is installed. Either install `tzdata-legacy` or switch the configured value to the +canonical ID (`America/New_York`, `Asia/Kolkata`). + +`TZDIR` is honoured if the data lives somewhere non-standard. + +## How the check works + +`--check-prereqs` asks the loader directly — `NativeLibrary.TryLoad` on the unversioned name, then +`libfoo.so.N` descending through the accepted range. + +It deliberately does not consult a package database or `ldconfig -p`. Both answer a different +question than "will `dlopen` succeed": + +- Package queries need a hardcoded name, which does not exist for ICU. +- `ldconfig`'s cache can be stale, omits `LD_LIBRARY_PATH`, and carries no version information to + enforce the ICU floor against. On musl it exits successfully while producing nothing usable. From b2c59191bdd02885d718327bbfcef57a20cd6ad0 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:24:59 -0700 Subject: [PATCH 30/64] fix: Fixes Argon2 verify correctness and the password upgrade lockout (#2562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ⚠️ **Rollback hazard — one-way door once logins are taken.** Serialization is unchanged, so a save > written by this build still *loads* on the previous one. Its contents do not survive the trip: on > its first successful login each account is rehashed to `$argon2id$`, and the previous build ships > Argon2.Bindings 1.19.0, whose `Verify` is gated by the verifier's own configured type and answers > `false` for an `$argon2id$` hash. **After a shard running this build has accepted logins, do not > roll back past this commit** — every account that logged in is locked out on the older binary, and > the only recovery is rolling forward again or resetting passwords by hand. Roll back only from a > save taken before the first post-deploy login. Requires [Argon2.Bindings 1.20.0](https://github.com/modernuo/Argon2.Bindings/pull/14), now published. ## What - Consume `Argon2.Bindings` 1.20.0, which resolves the Argon2 type from the stored PHC string rather than from the verifier's own configuration. - Default to **Argon2id, m=16384, t=1, p=1** — 8.51 ms against the old Argon2i 8 MiB t=3 at 10.11 ms. Cheaper *and* stronger. - Rehash on a successful login whenever the stored parameters are stale, not only when the algorithm changes. - Fix `SetPassword`, which derived the password phrase from the outgoing algorithm while storing it under the incoming one. ## Why **Verification was gated by the verifier's configured type.** `Verify` passed the instance's own `ArgonType` to native `argon2_verify`, whose `decode_string` rejects a disagreeing `$argon2i$`/`$argon2id$` prefix and returns `DECODING_FAIL` — folded into `false`, the same answer as a wrong password. Switching the default type would have locked out every existing account, and `VerifyAndUpdate` could not have migrated them either: it delegates to the same type-fixed `Verify` and never compared `ArgonType`. Fixed upstream in 1.20.0. The pinned legacy-`$argon2i$` test here fails on 1.19.0 for exactly that reason, which is what makes the package bump load-bearing rather than incidental. **Changing the defaults would otherwise have reached nobody.** Argon2's PHC string embeds `m`, `t` and `p`, so verification uses the parameters stored with each account, not the configured ones — and verification is the hot path. `CheckPassword` only rehashed when the *algorithm* changed, never when its cost parameters did, so on an established shard the new defaults would have applied to new accounts only. `IPasswordProtection.NeedsRehash` closes that: it defaults to `false`, so PBKDF2 and the `HashAlgorithm` protections are untouched — only Argon2 carries its cost inside the stored value. **`SetPassword` picked the phrase rule from the wrong algorithm.** SHA1 and SHA2 salt the phrase with the username; Argon2 and PBKDF2 do not. It chose the rule from the *outgoing* algorithm while storing under the *incoming* one, so any algorithm change wrote a credential its own next verify could not reproduce. It now assigns `PasswordAlgorithm` first and derives the phrase from that. Note this ordering is load-bearing and invisible — `UpgradingAlgorithm_DoesNotLockTheAccountOut` is what pins it. ## Cost Verification is re-derivation, so these are login numbers. A full login calls `CheckPassword` twice — `AccountLogin` (0x80) then `GameLogin` (0x91): **~20 ms before, ~17 ms after**, plus a one-time ~8.5 ms rehash on each account's migrating login. That cost is still paid on the game loop. Moving hashing off-loop is deliberately **not** in this PR — it needs a pending-auth state in the login handlers, bounding of in-flight hashes, and login rate limiting. --- .../Fixtures/TestServerInitializer.cs | 3 + .../Tests/Accounting/AccountPasswordTests.cs | 74 +++++++++++++++++++ .../Security/PasswordProtectionTest.cs | 60 +++++++++++++++ Projects/UOContent/Accounting/Account.cs | 7 +- .../Accounting/IPasswordProtection.cs | 7 ++ .../Security/Argon2PasswordProtection.cs | 33 ++++++++- Projects/UOContent/UOContent.csproj | 2 +- dev-docs/configuration.md | 1 + 8 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 640c05837..1325700d5 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -100,6 +100,9 @@ internal static class TestServerInitializer } World.Configure(); + // Registers the Accounts entity persistence, without which Accounts.NewAccount cannot + // resolve and no test can construct an Account. + Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); MovementImpl.Configure(); PathFollower.Configure(); diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs new file mode 100644 index 000000000..14e6089cd --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs @@ -0,0 +1,74 @@ +using System; +using Server.Accounting; +using Server.Accounting.Security; +using Xunit; + +namespace Server.Tests.Accounting; + +[Collection("Sequential UOContent Tests")] +public class AccountPasswordTests : IDisposable +{ + private const string Password = "hunter2"; + + // CurrentAlgorithm is process-wide state shared with the rest of the collection. + private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + + public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + [InlineData(PasswordProtectionAlgorithm.PBKDF2)] + [InlineData(PasswordProtectionAlgorithm.Argon2)] + public void NewAccount_CanLogIn(PasswordProtectionAlgorithm algorithm) + { + AccountSecurity.CurrentAlgorithm = algorithm; + var account = new Account($"new-{algorithm}-user", Password); + + Assert.Equal(algorithm, account.PasswordAlgorithm); + Assert.True(account.CheckPassword(Password)); + Assert.False(account.CheckPassword("wrong-password")); + } + + // SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversing those two + // lines salts the hash by the outgoing algorithm's rule and stores it under the incoming one, + // which verifies once and then never again. + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + [InlineData(PasswordProtectionAlgorithm.PBKDF2)] + public void UpgradingAlgorithm_DoesNotLockTheAccountOut(PasswordProtectionAlgorithm from) + { + AccountSecurity.CurrentAlgorithm = from; + var account = new Account($"upgrade-{from}-user", Password); + Assert.True(account.CheckPassword(Password)); + + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + + Assert.True(account.CheckPassword(Password)); + Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm); + + // Must verify against what the rehash wrote. + Assert.True(account.CheckPassword(Password)); + Assert.False(account.CheckPassword("wrong-password")); + } + + [Fact] + public void StaleArgon2Parameters_AreRehashedOnLogin() + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + var account = new Account("stale-params-user", Password); + + // The shipping default before this change: Argon2i, m=8192, t=3, p=1. + account.Password = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + Assert.True(account.CheckPassword(Password)); + Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password); + + // Already current: verifying again must not rewrite the hash. + var afterFirst = account.Password; + Assert.True(account.CheckPassword(Password)); + Assert.Equal(afterFirst, account.Password); + } +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index effd2da88..333c35692 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -74,4 +74,64 @@ public class PasswordProtectionTest Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); } + + // The shipping default before this change. A literal, so it cannot drift with the configured + // defaults. Password: "hunter2". + private const string LegacyArgon2iHash = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + [Fact] + public void Argon2_ValidatesLegacyArgon2iHash() + { + Assert.True(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "hunter2")); + Assert.False(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "wrong")); + } + + [Theory] + // type, memory, time, parallelism -> expected NeedsRehash + [InlineData("argon2id", 16384, 1, 1, false)] // current defaults + [InlineData("argon2i", 8192, 3, 1, true)] // the old shipping default + [InlineData("argon2id", 8192, 1, 1, true)] // right type, stale memory + [InlineData("argon2id", 16384, 3, 1, true)] // right type, stale iterations + [InlineData("argon2id", 16384, 1, 2, true)] // right type, stale parallelism + [InlineData("argon2i", 16384, 1, 1, true)] // right cost, stale type + public void Argon2_NeedsRehash_ComparesTypeAndCost( + string type, int memory, int time, int parallelism, bool expected + ) + { + var hash = $"${type}$v=19$m={memory},t={time},p={parallelism}$" + + "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + // Digest and salt lengths are the decoded sizes of the base64 segments, not parameter-list + // entries, so they need their own literals. Current type and cost throughout; only a length + // differs from the defaults. The theory above is the negative control at default lengths. + [Theory] + // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. + [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] + // 8-byte salt: 11 base64 chars instead of the 22 a 16-byte salt encodes to. + [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] + public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string hash) + { + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + [Theory] + [InlineData("")] + [InlineData("not-a-hash")] + public void Argon2_NeedsRehash_IsTrueForUnparseableHashes(string hash) + { + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + [Fact] + public void NonArgon2Protections_NeverNeedRehash() + { + Assert.False(PBKDF2PasswordProtection.Instance.NeedsRehash("anything")); + Assert.False(HashAlgorithmPasswordProtection.SHA2Instance.NeedsRehash("anything")); + Assert.False(HashAlgorithmPasswordProtection.SHA1Instance.NeedsRehash("anything")); + Assert.False(HashAlgorithmPasswordProtection.MD5Instance.NeedsRehash("anything")); + } } diff --git a/Projects/UOContent/Accounting/Account.cs b/Projects/UOContent/Accounting/Account.cs index d79d70b1a..45ede29bb 100644 --- a/Projects/UOContent/Accounting/Account.cs +++ b/Projects/UOContent/Accounting/Account.cs @@ -378,12 +378,12 @@ public partial class Account : IAccount, IComparable public void SetPassword(string plainPassword) { - var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 + PasswordAlgorithm = AccountSecurity.CurrentAlgorithm; + var phrase = PasswordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 ? $"{_username}{plainPassword}" : plainPassword; Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase); - PasswordAlgorithm = AccountSecurity.CurrentAlgorithm; } public bool CheckPassword(string plainPassword) @@ -399,7 +399,8 @@ public partial class Account : IAccount, IComparable } // Upgrade the password protection in case we change the algorithm - if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm) + if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm || + AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password)) { SetPassword(plainPassword); } diff --git a/Projects/UOContent/Accounting/IPasswordProtection.cs b/Projects/UOContent/Accounting/IPasswordProtection.cs index 0fdd6ced9..f5590a148 100644 --- a/Projects/UOContent/Accounting/IPasswordProtection.cs +++ b/Projects/UOContent/Accounting/IPasswordProtection.cs @@ -4,5 +4,12 @@ namespace Server.Accounting { string EncryptPassword(string plainPassword); bool ValidatePassword(string encryptedPassword, string plainPassword); + + /// + /// True when was produced with parameters that differ + /// from the ones this protection currently uses, so a successful login should rewrite it. + /// Algorithms whose cost is not embedded in the stored value never need this. + /// + bool NeedsRehash(string encryptedPassword) => false; } } diff --git a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs index 99f320a78..3b509e099 100644 --- a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs @@ -21,11 +21,38 @@ public class Argon2PasswordProtection : IPasswordProtection { public static IPasswordProtection Instance = new Argon2PasswordProtection(); - private readonly Argon2PasswordHasher m_PasswordHasher = new(rng: RandomNumberGenerator.Create()); + // 16 MiB at t=1 is cheaper than 8 MiB at t=3 (8.5 ms vs 10.1 ms) and twice as memory-hard, which + // is what resists GPU and ASIC cracking. p=1: native argon2 spawns a thread per lane. + private readonly Argon2PasswordHasher _passwordHasher = new( + time: 1, + memory: 16384, + parallel: 1, + type: Argon2Type.Argon2id, + rng: RandomNumberGenerator.Create() + ); public string EncryptPassword(string plainPassword) => - m_PasswordHasher.Hash(plainPassword); + _passwordHasher.Hash(plainPassword); public bool ValidatePassword(string encryptedPassword, string plainPassword) => - m_PasswordHasher.Verify(encryptedPassword, plainPassword); + _passwordHasher.Verify(encryptedPassword, plainPassword); + + // The PHC string carries the parameters it was hashed with, so verification uses those rather + // than the configured ones. Comparing them is what lets a parameter change reach existing + // accounts. + public bool NeedsRehash(string encryptedPassword) + { + // Unparseable but verified: a format this build does not understand, so rewrite it. + if (!Argon2PasswordHasher.TryExtractMetadataValues(encryptedPassword, out var values)) + { + return true; + } + + return values.ArgonType != _passwordHasher.ArgonType + || values.MemoryCost != _passwordHasher.MemoryCost + || values.TimeCost != _passwordHasher.TimeCost + || values.Parallelism != _passwordHasher.Parallelism + || values.HashLength != (int)_passwordHasher.HashLength + || values.SaltLength != (int)_passwordHasher.SaltLength; + } } diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index bf288e629..d9961f38d 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -45,7 +45,7 @@ - + diff --git a/dev-docs/configuration.md b/dev-docs/configuration.md index 9ddc348a7..b69349451 100644 --- a/dev-docs/configuration.md +++ b/dev-docs/configuration.md @@ -98,6 +98,7 @@ Examples from the codebase: accountHandler.enableAutoAccountCreation accountHandler.enablePlayerPasswordCommand accountHandler.maxAccountsPerIP +accountSecurity.encryptionAlgorithm autosave.enabled autosave.saveDelay world.savePath From 64e6fe5da8d4b5244c98b61ed3731ccf54b3960c Mon Sep 17 00:00:00 2001 From: Guflly <145608489+Guflly@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:55:12 -0700 Subject: [PATCH 31/64] fix: Warn when sending empty gumps (#2563) ### Summary Generates a console warning when users receive an empty gump. This will help prevent client side leaks. --- .../Tests/Gumps/TestGumps/DynamicTestGump.cs | 2 + .../Tests/Gumps/TestGumps/EmptyTestGumps.cs | 40 +++++++++++++++++++ .../Tests/Gumps/TestGumps/LegacyTestGump.cs | 2 + .../Tests/Gumps/TestGumps/StaticTestGump.cs | 2 + .../Tests/Gumps/TestLayoutGumps.cs | 26 ++++++++++++ Projects/UOContent/Gumps/Base/BaseGump.cs | 8 ++++ Projects/UOContent/Gumps/Base/DynamicGump.cs | 1 + .../Gumps/Base/DynamicGumpBuilder.cs | 1 + .../UOContent/Gumps/Base/GumpLayoutBuilder.cs | 23 +++++++++++ Projects/UOContent/Gumps/Base/Legacy/Gump.cs | 5 +++ Projects/UOContent/Gumps/Base/StaticGump.cs | 3 ++ .../UOContent/Gumps/Base/StaticGumpBuilder.cs | 1 + 12 files changed, 114 insertions(+) create mode 100644 Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs index 998e4c537..fd3dafc56 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs @@ -6,6 +6,8 @@ public class DynamicTestGump : DynamicGump { private readonly string _petName; + public bool HasVisualElementsForTest => HasVisualElements; + public DynamicTestGump(string petName) : base(50, 50) { _petName = petName; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs new file mode 100644 index 000000000..b99de1700 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs @@ -0,0 +1,40 @@ +using Server.Gumps; + +namespace Server.Tests.Gumps; + +public sealed class EmptyLegacyTestGump : Gump +{ + public bool HasVisualElementsForTest => HasVisualElements; + + public EmptyLegacyTestGump() : base(0, 0) + { + } +} + +public sealed class EmptyDynamicTestGump : DynamicGump +{ + public bool HasVisualElementsForTest => HasVisualElements; + + public EmptyDynamicTestGump() : base(0, 0) + { + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + } +} + +public sealed class EmptyStaticTestGump : StaticGump +{ + public bool HasVisualElementsForTest => HasVisualElements; + + public EmptyStaticTestGump() : base(0, 0) + { + } + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.SetNoClose(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs index 668d7e8ae..e45514c38 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs @@ -4,6 +4,8 @@ namespace Server.Tests.Gumps; public sealed class LegacyTestGump : Gump { + public bool HasVisualElementsForTest => HasVisualElements; + public LegacyTestGump(string petName) : base(50, 50) { Serial = (Serial)0x123; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs index ea67720f5..66d80c963 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs @@ -4,6 +4,8 @@ namespace Server.Tests.Gumps; public class StaticTestGump : StaticGump { + public bool HasVisualElementsForTest => HasVisualElements; + public StaticTestGump() : base(50, 50) { Serial = (Serial)0x123; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs b/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs index 7cd8e67d1..804408fc7 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs @@ -73,6 +73,32 @@ public class TestLayoutGumps AssertThat.Equal(writer.Span, packet); } + [Fact] + public void TestEmptyGumpsHaveNoVisualElements() + { + Assert.False(Compile(new EmptyLegacyTestGump()).HasVisualElementsForTest); + Assert.False(Compile(new EmptyDynamicTestGump()).HasVisualElementsForTest); + Assert.False(Compile(new EmptyStaticTestGump()).HasVisualElementsForTest); + Assert.False(Compile(new EmptyStaticTestGump()).HasVisualElementsForTest); + } + + [Fact] + public void TestVisibleGumpsHaveVisualElements() + { + Assert.True(Compile(new LegacyTestGump("Test")).HasVisualElementsForTest); + Assert.True(Compile(new DynamicTestGump("Test")).HasVisualElementsForTest); + Assert.True(Compile(new StaticTestGump()).HasVisualElementsForTest); + Assert.True(Compile(new StaticTestGump()).HasVisualElementsForTest); + } + + private static T Compile(T gump) where T : BaseGump + { + var buffer = GC.AllocateUninitializedArray(512); + var writer = new SpanWriter(buffer); + gump.Compile(ref writer); + return gump; + } + private static void InternalTestStaticGump(ReadOnlySpan expectedLayout, StaticGump staticGump, string[] strings) where T : StaticGump { diff --git a/Projects/UOContent/Gumps/Base/BaseGump.cs b/Projects/UOContent/Gumps/Base/BaseGump.cs index 6d844735d..05770608c 100644 --- a/Projects/UOContent/Gumps/Base/BaseGump.cs +++ b/Projects/UOContent/Gumps/Base/BaseGump.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * *************************************************************************/ +using Server.Logging; using Server.Network; using System; using System.Buffers; @@ -23,10 +24,12 @@ namespace Server.Gumps; public abstract class BaseGump { private static readonly byte[] _packetBuffer = GC.AllocateUninitializedArray(0x10000); + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(BaseGump)); private static Serial nextSerial = (Serial)1; public int TypeID { get; protected set; } public Serial Serial { get; protected set; } + protected bool HasVisualElements { get; set; } public abstract int Switches { get; } public abstract int TextEntries { get; } @@ -56,6 +59,11 @@ public abstract class BaseGump var writer = new SpanWriter(_packetBuffer); Compile(ref writer); + if (!HasVisualElements) + { + _logger.Warning("Sending empty gump {GumpType}", GetType().FullName); + } + ns.Send(writer.Span); writer.Dispose(); diff --git a/Projects/UOContent/Gumps/Base/DynamicGump.cs b/Projects/UOContent/Gumps/Base/DynamicGump.cs index f1b3c064a..de894da68 100644 --- a/Projects/UOContent/Gumps/Base/DynamicGump.cs +++ b/Projects/UOContent/Gumps/Base/DynamicGump.cs @@ -47,6 +47,7 @@ public abstract class DynamicGump : BaseGump BuildLayout(ref gumpBuilder); gumpBuilder.FinalizeLayout(); + HasVisualElements = gumpBuilder.HasVisualElements; _switches = gumpBuilder.Switches; _textEntries = gumpBuilder.TextEntries; diff --git a/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs b/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs index e98c8c6df..9238e6e04 100644 --- a/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs +++ b/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs @@ -36,6 +36,7 @@ public ref struct DynamicGumpBuilder public int Switches => _gumpBuilder._switches; public int TextEntries => _gumpBuilder._textEntries; + internal bool HasVisualElements => _gumpBuilder._hasVisualElements; [MethodImpl(MethodImplOptions.AggressiveInlining)] public DynamicGumpBuilder() diff --git a/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs b/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs index 7f0ca5dff..4bd5c7d8f 100644 --- a/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs +++ b/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs @@ -30,6 +30,7 @@ public ref struct GumpLayoutBuilder internal GumpFlags _flags; internal int _switches; internal int _textEntries; + internal bool _hasVisualElements; internal Span LayoutData => _layoutBuffer.AsSpan(0, _bytesWritten); @@ -95,6 +96,7 @@ public ref struct GumpLayoutBuilder public void AddBackground(int x, int y, int width, int height, int gumpId) { + _hasVisualElements = true; GrowIfNeeded(9 + 9 + 45); WriteStart("resizepic"u8); WriteValue(x); @@ -109,6 +111,7 @@ public ref struct GumpLayoutBuilder int x, int y, int normalId, int pressedId, int buttonId, GumpButtonType type = GumpButtonType.Reply, int param = 0 ) { + _hasVisualElements = true; GrowIfNeeded(11 + 6 + 54 + 2); WriteStart("button"u8); WriteValue(x); @@ -123,6 +126,7 @@ public ref struct GumpLayoutBuilder public void AddCheckbox(int x, int y, int inactiveId, int activeId, bool selected, int switchId) { + _hasVisualElements = true; GrowIfNeeded(10 + 8 + 45 + 2); WriteStart("checkbox"u8); WriteValue(x); @@ -154,6 +158,7 @@ public ref struct GumpLayoutBuilder public int AddHtmlPlaceholder(int x, int y, int width, int height, bool background = false, bool scrollbar = false) { + _hasVisualElements = true; GrowIfNeeded(11 + 8 + 36 + 10); WriteStart("htmlgump"u8); WriteValue(x); @@ -172,6 +177,7 @@ public ref struct GumpLayoutBuilder public void AddHtml(int x, int y, int width, int height, int text, bool background = false, bool scrollbar = false) { + _hasVisualElements = true; GrowIfNeeded(11 + 8 + 45 + 4); WriteStart("htmlgump"u8); WriteValue(x); @@ -188,6 +194,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int number, bool background = false, bool scrollbar = false ) { + _hasVisualElements = true; GrowIfNeeded(11 + 11 + 45 + 4); WriteStart("xmfhtmlgump"u8); WriteValue(x); @@ -204,6 +211,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int number, int color, bool background = false, bool scrollbar = false ) { + _hasVisualElements = true; GrowIfNeeded(12 + 16 + 45 + 5 + 4); WriteStart("xmfhtmlgumpcolor"u8); WriteValue(x); @@ -220,6 +228,7 @@ public ref struct GumpLayoutBuilder public void AddHtmlLocalized(int x, int y, int width, int height, int number, ReadOnlySpan args, int color, bool background = false, bool scrollbar = false) { + _hasVisualElements = true; GrowIfNeeded(12 + 10 + 45 + 5 + 4 + (args.Length > 0 ? 3 + args.Length : 0)); WriteStart("xmfhtmltok"u8); WriteValue(x); @@ -254,6 +263,7 @@ public ref struct GumpLayoutBuilder public void AddImage(int x, int y, int gumpId, int hue = 0, ReadOnlySpan cls = default) { + _hasVisualElements = true; GrowIfNeeded(7 + 7 + 36 + (hue != 0 ? 14 : 0) + (cls.Length > 0 ? 7 + cls.Length : 0)); WriteStart("gumppic"u8); WriteValue(x); @@ -294,6 +304,7 @@ public ref struct GumpLayoutBuilder public void AddImageTiledButton(int x, int y, int normalId, int pressedId, int buttonId, GumpButtonType type, int param, int itemId, int hue, int width, int height, int localizedTooltip = -1) { + _hasVisualElements = true; GrowIfNeeded(15 + 13 + 90 + 2); WriteStart("buttontileart"u8); WriteValue(x); @@ -319,6 +330,7 @@ public ref struct GumpLayoutBuilder public void AddImageTiled(int x, int y, int width, int height, int gumpId) { + _hasVisualElements = true; GrowIfNeeded(9 + 12 + 45); WriteStart("gumppictiled"u8); WriteValue(x); @@ -331,6 +343,7 @@ public ref struct GumpLayoutBuilder public void AddItem(int x, int y, int itemId, int hue = 0) { + _hasVisualElements = true; GrowIfNeeded(7 + 36 + (hue != 0 ? 20 : 7)); WriteStart(hue == 0 ? "tilepic"u8 : "tilepichue"u8); WriteValue(x); @@ -355,6 +368,7 @@ public ref struct GumpLayoutBuilder public int AddLabelPlaceholder(int x, int y, int hue) { + _hasVisualElements = true; GrowIfNeeded(8 + 4 + 27 + 6); WriteStart("text"u8); WriteValue(x); @@ -368,6 +382,7 @@ public ref struct GumpLayoutBuilder public void AddLabel(int x, int y, int hue, int text) { + _hasVisualElements = true; GrowIfNeeded(8 + 4 + 36 + 6); WriteStart("text"u8); WriteValue(x); @@ -379,6 +394,7 @@ public ref struct GumpLayoutBuilder public int AddLabelCroppedPlaceholder(int x, int y, int width, int height, int hue) { + _hasVisualElements = true; GrowIfNeeded(10 + 11 + 45 + 6); WriteStart("croppedtext"u8); WriteValue(x); @@ -394,6 +410,7 @@ public ref struct GumpLayoutBuilder public void AddLabelCropped(int x, int y, int width, int height, int hue, int text) { + _hasVisualElements = true; GrowIfNeeded(10 + 11 + 54 + 6); WriteStart("croppedtext"u8); WriteValue(x); @@ -430,6 +447,7 @@ public ref struct GumpLayoutBuilder public void AddRadio(int x, int y, int inactiveId, int activeId, bool selected, int switchId) { + _hasVisualElements = true; GrowIfNeeded(10 + 5 + 45 + 2); WriteStart("radio"u8); WriteValue(x); @@ -445,6 +463,7 @@ public ref struct GumpLayoutBuilder public void AddSpriteImage(int x, int y, int gumpId, int width, int height, int sx, int sy) { + _hasVisualElements = true; GrowIfNeeded(11 + 8 + 63); WriteStart("picinpic"u8); WriteValue(x); @@ -461,6 +480,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId ) { + _hasVisualElements = true; GrowIfNeeded(11 + 9 + 54 + 6); WriteStart("textentry"u8); WriteValue(x); @@ -481,6 +501,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int initialText ) { + _hasVisualElements = true; GrowIfNeeded(11 + 9 + 63 + 6); WriteStart("textentry"u8); WriteValue(x); @@ -499,6 +520,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int size = 0 ) { + _hasVisualElements = true; GrowIfNeeded(12 + 16 + 63 + 6); WriteStart("textentrylimited"u8); WriteValue(x); @@ -520,6 +542,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int initialText, int size = 0 ) { + _hasVisualElements = true; GrowIfNeeded(12 + 16 + 72); WriteStart("textentrylimited"u8); WriteValue(x); diff --git a/Projects/UOContent/Gumps/Base/Legacy/Gump.cs b/Projects/UOContent/Gumps/Base/Legacy/Gump.cs index 5cfb1f14a..3be1ddebf 100644 --- a/Projects/UOContent/Gumps/Base/Legacy/Gump.cs +++ b/Projects/UOContent/Gumps/Base/Legacy/Gump.cs @@ -249,6 +249,7 @@ public class Gump : BaseGump { _textEntries = 0; _switches = 0; + HasVisualElements = false; var layoutWriter = new SpanWriter(_layoutBuffer); @@ -274,6 +275,7 @@ public class Gump : BaseGump foreach (var entry in Entries) { + HasVisualElements |= IsVisualEntry(entry); entry.AppendTo(ref layoutWriter, _stringsList, ref _textEntries, ref _switches); } @@ -311,6 +313,9 @@ public class Gump : BaseGump } } + private static bool IsVisualEntry(GumpEntry entry) => + entry is not (GumpAlphaRegion or GumpECHandleInput or GumpGroup or GumpItemProperty or GumpMasterGump or GumpPage or GumpTooltip); + protected void Reset() { _switches = 0; diff --git a/Projects/UOContent/Gumps/Base/StaticGump.cs b/Projects/UOContent/Gumps/Base/StaticGump.cs index a10491618..0ab2df5e0 100644 --- a/Projects/UOContent/Gumps/Base/StaticGump.cs +++ b/Projects/UOContent/Gumps/Base/StaticGump.cs @@ -30,6 +30,7 @@ public abstract class StaticGump : BaseGump where TSelf : StaticGump : BaseGump where TSelf : StaticGump : BaseGump where TSelf : StaticGump _gumpBuilder._switches; public int TextEntries => _gumpBuilder._textEntries; + internal bool HasVisualElements => _gumpBuilder._hasVisualElements; [MethodImpl(MethodImplOptions.AggressiveInlining)] public StaticGumpBuilder() From f33bcd6006567ea443e84dfa91e994e78d4d9e1e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:25:42 -0700 Subject: [PATCH 32/64] fix: Bind the login auth id to its account and drop the redundant verify (#2564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What - Bind the login auth id to the account **and** origin address that earned it, make it a CSPRNG draw, expire it after two minutes, and spend it only once its owner presents it. - Skip the password verify on `GameLogin` (0x91) when the presented id vouches for the submitted username and address. ## Why A full client login hashes the password twice — `AccountLogin` (0x80) and then `GameLogin` (0x91). At the current Argon2 parameters that is **most of a 16 ms frame each, on the single-threaded game loop**, for every login attempt. The second verify is redundant. `GameLogin` already requires an id from `_authIDWindow`, and that window is only populated by `GenerateAuthID`, called from `PlayServer` — reachable only after 0x80 has already authenticated the account **in this same process**. ModernUO Gateway has its own auth-id passing mechanism and is out of scope here. ## Why the id needed hardening first Skipping the verify promotes the id from a correlation token to a bearer token, and it was not one: - drawn from `Utility.Random` → `BuiltInRng`, a non-cryptographic PRNG - bound to nothing — `AuthIDPersistence` carried only `Age` and `Version` - never expiring; `Age` was only read to pick an eviction victim A guessed id got you nothing while the password was still checked. Without that check it would have been an account takeover, so the id is now a CSPRNG draw, single-use, two-minute TTL, and bound to both the account and the origin address. What remains is observing a live id on the client's network or machine — which the server cannot defend against under any design, and which already yields the password itself, since the client transmits it in the same handshake. Network switching mid-login is deliberately unsupported. ## Behaviour A full verify was always required before this change, and ids never expired, so every "before" is a password check. | Case | Before | After | |---|---|---| | Id absent | Disconnect | Disconnect | | Address mismatch | Verify | **Disconnect** | | Account mismatch | Verify | **Disconnect** | | Expired | Verify | **Verify** | | Id vouches | Verify | **Skip** | No case grants access the previous code would have denied. Expiry deliberately falls back to the verify rather than disconnecting — a player can idle, and turning that into a lockout would be a regression for no gain. ## Look, then take An id is not consumed until the presenter has shown it is theirs. Removing it first would let anyone who lands on a live id burn it, and its owner would arrive to `"Unable to find auth id."` and have to log in again over a packet they had no part in. The **address is compared before the account**, so a guesser from anywhere else is rejected before a username is ever looked at. That is what makes it safe to leave the id in place on a mismatch: there is no username-enumeration risk to trade against, and the only presenter who could enumerate is already on the victim's own address. ## The window is not a cap It was 128 entries with the oldest evicted to make room. That is a cap on *concurrent logins*, not a resource bound: 800 people picking a server at once would have live ids discarded and those clients would arrive to `"Unable to find auth id."` — a failed login caused by nothing except other people logging in. Issuing now sweeps expired entries and lets the window grow if everything in it is still live. Unbounded is safe here: an entry costs a **successful** password verify to create and dies after two minutes, so its size tracks logins genuinely in flight. Removing an id when its connection drops is not an option, and this was checked rather than assumed — `NetState.cs:787` disconnects the login connection *deliberately*, immediately after the id is issued, and that disconnect is never cancelled. Surviving it is the whole purpose of the id. Expiry is the only correct reclamation. ## Handshake hardening Choosing a server queues a disconnect, but the queue drains on the *next* slice, so a client pipelining into the same recv buffer can reach the handshake handlers again. Two had no do-once guard: - `LoginServerSeed` (0xEF) now rejects when `state.Seeded` is already set. - `PlayServer` (0xA0) now rejects when `state.AuthId != 0` — otherwise a connection that had already spent its id would be handed the spent one back. Issuing is also idempotent (`EnsureAuthId`), so a connection holds exactly one id by construction and an orphan is impossible rather than something to clean up. The login state machine itself is untouched. Also fixes a fall-through: the "Unable to find auth id" branch disconnected without returning, then continued with a default entry and nulled `state.Version`. ## Testing `ConsumeAuthId` is a seam with no `NetState` dependency, so the auth decision is tested directly: vouching, account mismatch, address mismatch, case-insensitive usernames, IPv4-mapped-IPv6, unknown ids, single-use by the owner, **a rejected attempt leaving the id redeemable**, expiry-into-verify, and an 800-id login rush that must evict nobody. Expiry is driven by moving `Core._now`, not by waiting. Every new clause was verified to discriminate by removing it and confirming only its own tests fail. ## Cost Halves the per-login game-loop cost. This does not make hashing cheaper or move it off the loop — that is gated on a measurement described in `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md`. --- .../Fixtures/TestServerInitializer.cs | 3 +- .../Tests/Accounting/AccountPasswordTests.cs | 6 +- .../Security/PasswordProtectionTest.cs | 7 +- .../Tests/Network/Packets/AuthIdTests.cs | 382 ++++++++++++++++++ .../UOContent/Accounting/AccountHandler.cs | 4 +- .../Security/Argon2PasswordProtection.cs | 5 +- Projects/UOContent/Network/GameServer.cs | 10 +- .../Network/Packets/IncomingAccountPackets.cs | 178 ++++++-- 8 files changed, 545 insertions(+), 50 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 1325700d5..a38af9c3d 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -100,8 +100,7 @@ internal static class TestServerInitializer } World.Configure(); - // Registers the Accounts entity persistence, without which Accounts.NewAccount cannot - // resolve and no test can construct an Account. + // Registers the Accounts entity persistence; without it no test can construct an Account. Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); MovementImpl.Configure(); diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs index 14e6089cd..29a1b818f 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs @@ -30,9 +30,9 @@ public class AccountPasswordTests : IDisposable Assert.False(account.CheckPassword("wrong-password")); } - // SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversing those two - // lines salts the hash by the outgoing algorithm's rule and stores it under the incoming one, - // which verifies once and then never again. + // SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversed, the hash + // is salted by the outgoing algorithm's rule but stored under the incoming one, which verifies + // once and then never again. [Theory] [InlineData(PasswordProtectionAlgorithm.SHA1)] [InlineData(PasswordProtectionAlgorithm.SHA2)] diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index 333c35692..b86df418d 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -75,7 +75,7 @@ public class PasswordProtectionTest Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); } - // The shipping default before this change. A literal, so it cannot drift with the configured + // The shipping default before this change, as a literal so it cannot drift with the configured // defaults. Password: "hunter2". private const string LegacyArgon2iHash = "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; @@ -105,9 +105,8 @@ public class PasswordProtectionTest Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); } - // Digest and salt lengths are the decoded sizes of the base64 segments, not parameter-list - // entries, so they need their own literals. Current type and cost throughout; only a length - // differs from the defaults. The theory above is the negative control at default lengths. + // Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they + // need their own literals. Current type and cost throughout; only a length differs. [Theory] // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] diff --git a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs new file mode 100644 index 000000000..456eb174a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs @@ -0,0 +1,382 @@ +using System; +using System.Net; +using Server.Accounting; +using Server.Accounting.Security; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace Server.Tests.Network.Packets; + +[Collection("Sequential UOContent Tests")] +public class AuthIdTests : IDisposable +{ + private static readonly IPAddress AddressX = IPAddress.Parse("203.0.113.10"); + private static readonly IPAddress AddressY = IPAddress.Parse("203.0.113.11"); + + private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + + public AuthIdTests() + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + IncomingAccountPackets.ClearAuthIdWindow(); + } + + public void Dispose() + { + IncomingAccountPackets.ClearAuthIdWindow(); + AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + } + + private static IAccount CreateAccount(string username) => + Accounts.GetAccount(username) ?? new Account(username, "hunter2"); + + private static int Register(IAccount account, IPAddress address) => + IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0)); + + [Fact] + public void VouchesForTheAccountAndAddressItWasIssuedTo() + { + var account = CreateAccount("authid-match-user"); + var authId = Register(account, AddressX); + + var result = IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry); + + Assert.Equal(IncomingAccountPackets.AuthIdResult.Vouched, result); + Assert.Same(account, entry.Account); + } + + [Fact] + public void RejectsADifferentAccount() + { + var issued = CreateAccount("authid-owner-user"); + var other = CreateAccount("authid-other-user"); + var authId = Register(issued, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, other.Username, AddressX, out _) + ); + } + + [Fact] + public void RejectsADifferentAddress() + { + var account = CreateAccount("authid-switch-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) + ); + } + + [Fact] + public void MatchesTheUsernameCaseInsensitively() + { + var account = CreateAccount("AuthId-Case-User"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, "authid-case-user", AddressX, out _) + ); + } + + [Fact] + public void MatchesAnIPv4MappedIPv6Address() + { + var account = CreateAccount("authid-mapped-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX.MapToIPv6(), out _) + ); + } + + [Fact] + public void RejectsAnUnknownAuthId() + { + var account = CreateAccount("authid-unknown-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId + 1, account.Username, AddressX, out _) + ); + } + + [Fact] + public void IsSingleUseAfterASuccess() + { + var account = CreateAccount("authid-once-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + // A rejected attempt must not consume the id, or anyone landing on a live one could burn it and + // force its owner to log in again. + [Fact] + public void SurvivesAnAttemptFromTheWrongAddress() + { + var account = CreateAccount("authid-not-burned-address-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) + ); + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + [Fact] + public void SurvivesAnAttemptForTheWrongAccount() + { + var account = CreateAccount("authid-not-burned-account-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _) + ); + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + [Fact] + public void ARejectedAttemptYieldsNoEntry() + { + var account = CreateAccount("authid-no-leak-user"); + var authId = Register(account, AddressX); + + IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out var entry); + + Assert.Null(entry.Account); + } + + [Fact] + public void AnExpiredIdIsSpentByItsOwner() + { + var account = CreateAccount("authid-expired-spent-user"); + var authId = Register(account, AddressX); + + var now = Core._now; + + try + { + Core._now = now + TimeSpan.FromMinutes(30.0); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Expired, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + Assert.Equal(0, IncomingAccountPackets.AuthIdWindowCount); + } + finally + { + Core._now = now; + } + } + + // Expiry is not a lockout. The game login always verified the password before any of this + // existed, so falling back to that verify is the behaviour we started from. + [Fact] + public void ExpiresIntoAPasswordVerifyRatherThanARejection() + { + var account = CreateAccount("authid-expired-user"); + var authId = Register(account, AddressX); + + var now = Core._now; + + try + { + Core._now = now + TimeSpan.FromMinutes(30.0); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Expired, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry) + ); + + // Still carries the client version the game login needs. + Assert.Equal(new ClientVersion(7, 0, 0, 0), entry.Version); + } + finally + { + Core._now = now; + } + } + + [Fact] + public void AnExpiredIdFromAnotherAddressIsStillRejected() + { + var account = CreateAccount("authid-expired-elsewhere-user"); + var authId = Register(account, AddressX); + + var now = Core._now; + + try + { + Core._now = now + TimeSpan.FromMinutes(30.0); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) + ); + } + finally + { + Core._now = now; + } + } + + private static int Ensure(int existingAuthId, IAccount account, IPAddress address) => + IncomingAccountPackets.EnsureAuthId( + existingAuthId, + account, + address, + new ClientVersion(7, 0, 0, 0) + ); + + [Fact] + public void IssuesAnIdWhenTheConnectionHasNone() + { + var account = CreateAccount("authid-first-select-user"); + + var authId = Ensure(0, account, AddressX); + + Assert.NotEqual(0, authId); + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + } + + // Handing the same id back rather than minting another is what makes an orphan impossible, + // instead of something to clean up afterwards. + [Fact] + public void ReSelectingReturnsTheSameIdAndAddsNothingToTheWindow() + { + var account = CreateAccount("authid-reselect-user"); + var first = Ensure(0, account, AddressX); + + for (var i = 0; i < 10; i++) + { + Assert.Equal(first, Ensure(first, account, AddressX)); + } + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(first, account.Username, AddressX, out _) + ); + } + + [Fact] + public void AbandonedIdsAreSweptWhenNewOnesAreIssued() + { + var abandoned = CreateAccount("authid-abandoned-user"); + var live = CreateAccount("authid-live-user"); + + var now = Core._now; + + try + { + for (var i = 0; i < 128; i++) + { + Register(abandoned, AddressX); + } + + Assert.Equal(128, IncomingAccountPackets.AuthIdWindowCount); + + Core._now = now + TimeSpan.FromMinutes(30.0); + + var liveId = Register(live, AddressX); + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(liveId, live.Username, AddressX, out _) + ); + } + finally + { + Core._now = now; + } + } + + // A login rush is not a backlog. Every id belongs to a client on its way to redeem it, so none + // may be discarded to hold the window at some arbitrary size. + [Fact] + public void ALoginRushDoesNotEvictAnyonesAuthId() + { + var account = CreateAccount("authid-rush-user"); + var ids = new int[800]; + + for (var i = 0; i < ids.Length; i++) + { + ids[i] = Register(account, AddressX); + } + + Assert.Equal(ids.Length, IncomingAccountPackets.AuthIdWindowCount); + + // Every id issued during the rush is still redeemable, including the first one. + for (var i = 0; i < ids.Length; i++) + { + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(ids[i], account.Username, AddressX, out _) + ); + } + } + + [Fact] + public void PreAuthenticatedGameLogin_SkipsThePasswordCheck() + { + var account = CreateAccount("authid-preauth-user"); + using var ns = PacketTestUtilities.CreateTestNetState(); + + // A wrong password is accepted only because the auth id already vouched for the account. + var e = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", true); + GameServer.GameServerLoginEvent(e); + + Assert.True(e.Accepted); + } + + [Fact] + public void GameLoginWithoutPreAuthentication_StillChecksThePassword() + { + var account = CreateAccount("authid-nopreauth-user"); + using var ns = PacketTestUtilities.CreateTestNetState(); + + var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false); + GameServer.GameServerLoginEvent(wrong); + Assert.False(wrong.Accepted); + + var right = new GameServer.GameLoginEventArgs(ns, account.Username, "hunter2", false); + GameServer.GameServerLoginEvent(right); + Assert.True(right.Accepted); + } + + [Fact] + public void GeneratesDistinctAuthIds() + { + var account = CreateAccount("authid-distinct-user"); + + Assert.NotEqual(Register(account, AddressX), Register(account, AddressX)); + } +} diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs index ea0c4b9e0..542e16a40 100644 --- a/Projects/UOContent/Accounting/AccountHandler.cs +++ b/Projects/UOContent/Accounting/AccountHandler.cs @@ -343,7 +343,9 @@ public static class AccountHandler logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); e.Accepted = false; } - else if (!acct.CheckPassword(pw)) + // The auth id was only issued after the account login packet verified this password, so + // re-deriving the hash costs a second Argon2 verify to answer the same question. + else if (!e.PreAuthenticated && !acct.CheckPassword(pw)) { logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); e.Accepted = false; diff --git a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs index 3b509e099..0a952117d 100644 --- a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs @@ -37,9 +37,8 @@ public class Argon2PasswordProtection : IPasswordProtection public bool ValidatePassword(string encryptedPassword, string plainPassword) => _passwordHasher.Verify(encryptedPassword, plainPassword); - // The PHC string carries the parameters it was hashed with, so verification uses those rather - // than the configured ones. Comparing them is what lets a parameter change reach existing - // accounts. + // Verification uses the parameters embedded in the PHC string, not the configured ones, so + // comparing them is what lets a parameter change reach existing accounts. public bool NeedsRehash(string encryptedPassword) { // Unparseable but verified: a format this build does not understand, so rewrite it. diff --git a/Projects/UOContent/Network/GameServer.cs b/Projects/UOContent/Network/GameServer.cs index 9fc170d69..b9de09515 100644 --- a/Projects/UOContent/Network/GameServer.cs +++ b/Projects/UOContent/Network/GameServer.cs @@ -6,13 +6,21 @@ public static partial class GameServer { public class GameLoginEventArgs { - public GameLoginEventArgs(NetState state, string un, string pw) + public GameLoginEventArgs(NetState state, string un, string pw, bool preAuthenticated) { State = state; Username = un; Password = pw; + PreAuthenticated = preAuthenticated; } + /// + /// The auth id presented on this game login was issued to this account, from this address, + /// after the account login packet verified the password. Read-only so a subscriber cannot + /// grant itself the skip. + /// + public bool PreAuthenticated { get; } + public NetState State { get; } public string Username { get; } diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 07a444d03..888eeb6d7 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -17,6 +17,9 @@ using System; using System.Buffers; using System.Collections.Generic; using System.IO; +using System.Net; +using System.Security.Cryptography; +using Server.Accounting; using Server.Engines.CharacterCreation; using Server.Misc; using Server.Mobiles; @@ -25,7 +28,16 @@ namespace Server.Network; public static class IncomingAccountPackets { + // Initial capacity and the point at which issuing sweeps expired ids. Not a cap; the window + // grows rather than evicting a live id. private const int _authIDWindowSize = 128; + + private static int _authIdPurgeThreshold = _authIDWindowSize; + + // The gap between PlayServerAck and the game login is seconds. Bounds how long a stolen id + // stays usable. + private static readonly TimeSpan _authIDLifetime = TimeSpan.FromMinutes(2.0); + private static readonly Dictionary _authIDWindow = new(_authIDWindowSize); @@ -34,13 +46,33 @@ public static class IncomingAccountPackets public DateTime Age; public readonly ClientVersion Version; - public AuthIDPersistence(ClientVersion v) + // GameLogin skips its password verify when both match, so the id is a bearer token and has + // to be bound to whatever earned it. + public readonly IAccount Account; + public readonly IPAddress Address; + + public AuthIDPersistence(ClientVersion v, IAccount account, IPAddress address) { Age = Core.Now; Version = v; + Account = account; + Address = Utility.Intern(address); } } + internal enum AuthIdResult + { + // No such id, or it was issued for a different account or address. + Rejected, + + // Right account and address, too old to stand in for the verify. Idling on the server list + // is normal, so this falls back to the password check rather than becoming a lockout. + Expired, + + // Issued to this account, from this address, recently. Stands in for the password verify. + Vouched + } + public static unsafe void Configure() { IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true); @@ -312,42 +344,92 @@ public static class IncomingAccountPackets } } - private static int GenerateAuthID(this NetState state) + private static int GenerateAuthID(this NetState state) => + EnsureAuthId(state.AuthId, state.Account, state.Address, state.Version); + + /// + /// One id per connection, by construction. Choosing a server queues a disconnect that is not + /// drained until the next slice, so a client pipelining another select into the same buffer + /// arrives here again; handing back the id it already holds cannot orphan one. + /// + internal static int EnsureAuthId(int existingAuthId, IAccount account, IPAddress address, ClientVersion version) + => existingAuthId != 0 ? existingAuthId : RegisterAuthId(account, address, version); + + internal static int RegisterAuthId(IAccount account, IPAddress address, ClientVersion version) { - if (_authIDWindow.Count == _authIDWindowSize) + // Sweep the ids left behind by clients that picked a server and never arrived, but never + // evict a live one to make room -- the client holding it is on its way to redeem it. If all + // are live the window grows, which is a login rush, not a backlog. Each entry costs a + // successful password verify, so the size is self-limiting. + if (_authIDWindow.Count >= _authIdPurgeThreshold) { - var oldestID = 0; - var oldest = DateTime.MaxValue; - - foreach (var (key, authId) in _authIDWindow) - { - if (authId.Age < oldest) - { - oldestID = key; - oldest = authId.Age; - } - } - - _authIDWindow.Remove(oldestID); + PurgeExpiredAuthIds(); + _authIdPurgeThreshold = Math.Max(_authIDWindowSize, _authIDWindow.Count * 2); } int authID; + // The id stands in for a password verify, so it has to be unguessable. Zero is reserved: + // GameLogin reads state.AuthId == 0 as "no auth id was issued". do { - authID = Utility.Random(1, int.MaxValue - 1); + authID = RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue); + } while (authID == 0 || _authIDWindow.ContainsKey(authID)); - if (Utility.RandomBool()) - { - authID |= 1 << 31; - } - } while (_authIDWindow.ContainsKey(authID)); - - _authIDWindow[authID] = new AuthIDPersistence(state.Version); + _authIDWindow[authID] = new AuthIDPersistence(version, account, address); return authID; } + /// + /// Spends an auth id, but only for the account and address it was issued to. An address + /// mismatch is rather than a fallback: network switching + /// mid-login is not supported. + /// + internal static AuthIdResult ConsumeAuthId(int authId, string username, IPAddress address, out AuthIDPersistence entry) + { + if (!_authIDWindow.TryGetValue(authId, out entry)) + { + return AuthIdResult.Rejected; + } + + // Look, then take: removing before ownership is proven would let anyone landing on a live id + // burn it, leaving its owner to log in again. Address before username, so a remote guesser + // never learns whether a username matched. + if (!Utility.Intern(address).Equals(entry.Address) + || entry.Account == null || !username.InsensitiveEquals(entry.Account.Username)) + { + entry = default; + return AuthIdResult.Rejected; + } + + // Theirs, so spend it. Expired counts as spent; it has done all it is ever going to do. + _authIDWindow.Remove(authId); + + return Core.Now - entry.Age > _authIDLifetime ? AuthIdResult.Expired : AuthIdResult.Vouched; + } + + private static void PurgeExpiredAuthIds() + { + var now = Core.Now; + + foreach (var (key, entry) in _authIDWindow) + { + if (now - entry.Age > _authIDLifetime) + { + _authIDWindow.Remove(key); + } + } + } + + internal static void ClearAuthIdWindow() + { + _authIDWindow.Clear(); + _authIdPurgeThreshold = _authIDWindowSize; + } + + internal static int AuthIdWindowCount => _authIDWindow.Count; + public static void GameLogin(NetState state, SpanReader reader) { if (state.SentFirstPacket) @@ -360,12 +442,6 @@ public static class IncomingAccountPackets var authId = reader.ReadInt32(); - if (!_authIDWindow.TryGetValue(authId, out var ap)) - { - state.LogInfo("Invalid client detected, disconnecting..."); - state.Disconnect("Unable to find auth id."); - } - if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed) { state.LogInfo("Invalid client detected, disconnecting..."); @@ -373,14 +449,28 @@ public static class IncomingAccountPackets return; } - _authIDWindow.Remove(authId); - state.Version = ap.Version; - state.Seeded = true; - var username = reader.ReadLatin1Safe(30); var password = reader.ReadLatin1Safe(30); - var e = new GameServer.GameLoginEventArgs(state, username, password); + var authResult = ConsumeAuthId(authId, username, state.Address, out var ap); + + if (authResult == AuthIdResult.Rejected) + { + state.LogInfo("Invalid client detected, disconnecting..."); + state.Disconnect("Unable to find auth id."); + return; + } + + state.Version = ap.Version; + state.Seeded = true; + + // Expired carries a usable entry; only the password verify skip is withheld. + var e = new GameServer.GameLoginEventArgs( + state, + username, + password, + authResult == AuthIdResult.Vouched + ); GameServer.GameServerLoginEvent(e); @@ -402,6 +492,14 @@ public static class IncomingAccountPackets public static void PlayServer(NetState state, SpanReader reader) { + // A server is picked once per connection. Picking again hands back an id this connection may + // already have spent on a game login, which the client could never redeem. + if (state.AuthId != 0) + { + state.Disconnect("Duplicate play server packet sent."); + return; + } + int index = reader.ReadInt16(); var info = state.ServerInfo; var a = state.Account; @@ -414,7 +512,7 @@ public static class IncomingAccountPackets { var si = info[index]; - state.AuthId = GenerateAuthID(state); + state.AuthId = state.GenerateAuthID(); state.SentFirstPacket = false; state.SendPlayServerAck(si, state.AuthId); @@ -423,6 +521,14 @@ public static class IncomingAccountPackets public static void LoginServerSeed(NetState state, SpanReader reader) { + // Seeding happens once per connection. A second one restarts a handshake this connection + // already completed, which no real client does. + if (state.Seeded) + { + state.Disconnect("Duplicate login server seed packet sent."); + return; + } + state.Seed = reader.ReadInt32(); state.Seeded = true; From cce035f1c390af328206a27d7d7c42a08f37324b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:50:01 -0700 Subject: [PATCH 33/64] fix: Removes unnecessary dictionary removal guards (#2565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What `Dictionary.Remove` and `HashSet.Remove` do not bump the collection's version, so removing an entry during a `foreach` does not invalidate the enumerator. A number of loops were still paying for a `PooledRefQueue`/`PooledRefList` to collect keys and drain them in a second pass. This drops those guards. ## Why it's safe Verified against .NET 10.0.10 rather than taken on trust, since the documented guarantee covers only `Dictionary.Remove` while several of these call sites are `HashSet` or enumerate `.Keys`/`.Values`: | Case | Result | |---|---| | `Dictionary` foreach + `Remove` | safe, all entries visited | | `Dictionary.Keys` / `.Values` foreach + `Remove` | safe, all entries visited | | `HashSet` foreach + `Remove` | safe, all entries visited | | `Dictionary` foreach + `Remove` **then `Add`** | throws `InvalidOperationException` | Reflection on `_version` confirms the mechanism: neither `Dictionary.Remove` nor `HashSet.Remove` touches it. Because `Remove` never bumps the version, the `Keys` and `Values` enumerators are just as safe as the dictionary's own, even though only `Dictionary.Remove` documents the behaviour. No entries were skipped in any case. The `HashSet` half is confirmed by [stephentoub on dotnet/dotnet-api-docs#8177](https://github.com/dotnet/dotnet-api-docs/issues/8177#issuecomment-1167251052): *"Both HashSet and Dictionary have been improved to support removal during enumeration. The docs may just benefit from updating."* The gap is in the documentation, not the runtime. `Remove` followed by `Add` in the same enumeration still throws. That is the line this PR does not cross. ## Guards removed `VisibilityList`, `ChampionTitleSystem`, `Channel`, `BombingRun`, `Ruleset`, `PuzzleChest`, `RaceChangeGump`, `StepCache`, `PlayerMurderSystem`, `VirtueSystem`, `ProjectedItem`, `StaminaSystem`, `AIGroupMovement`, `PromotedGuard`, `AutoDenylist`, `LoginAllowlist`, `AntiMacroSystem`, `DetectHidden`. Both collection kinds are covered: `Dictionary` (including loops over `.Keys` and `.Values`) and `HashSet` (`ProjectedItem._active`, `PlayerMurderSystem._contextTerms`, `StaminaSystem._resetHash`). In `StaminaSystem.ResetTimer` the `Count == queue.Count → Clear()` branch goes away with the queue — it only existed to avoid paying for N individual removes. Where the collection supports it, `Contains` + `Remove` and `TryGetValue` + `Remove` also collapse into a single lookup (`if (list.Remove(x))`, `if (m_Pending.Remove(ns, out var state))`). `Utility.Tidy` keeps its two branches: when `K` is serializable the value is not inspected, otherwise the value is. Only the serializable side may be cast, so `Dictionary` and `Dictionary` stay valid. ## Deliberately unchanged **`BaseCreature.LoyaltyTimer.OnTick`** keeps its deferred-delete queue. Removing from `World.Mobiles` while enumerating it is safe, but `Mobile.Delete()` is not a `Remove` — it runs `OnDelete`/`OnAfterDelete`, the `OnParentDeleted` cascade over the creature's pack, `DropHolding()`, and region and guild callbacks. Anything in that surface that constructs a `Mobile` is an `Add` into the dictionary being enumerated, which does invalidate it. `BaseHire.PayTimer.OnTick` has the same shape and is likewise untouched. **Spatial-query buffers** — `GuardedRegion.CallGuards`, `Thunderstorm`, `Exorcism`, `LeverPuzzleController`, `BaseCreature.TeleportPets` — are a different hazard. They buffer the result of a range query because the drain moves or harms mobiles, which mutates sectors mid-enumeration. **Re-entrant drains.** The `_users` sets in `Firebomb` and the explosion, conflagration and confusion-blast potions look like this pattern but are not: the loop collects, `Clear()`s, and only then runs `Target.Cancel` on each, which can re-enter. `AnimalTrainer` enumerates `pm.Stabled` and drains through `RemoveStabled`, which nulls the `Stabled` field once it empties — safe for an in-flight enumerator, which holds the set reference rather than the field, but subtle enough not to be worth inlining on a cold path. ## Verification `dotnet build` clean with 0 warnings; 810 Server and 684 UOContent tests pass. --- Projects/Server/Utilities/Utility.cs | 26 +++++------------ Projects/UOContent/Commands/VisibilityList.cs | 3 +- .../Engines/CannedEvil/ChampionTitleSystem.cs | 9 +----- Projects/UOContent/Engines/Chat/Channel.cs | 16 ++-------- .../Engines/ConPVP/Games/BombingRun.cs | 5 +--- Projects/UOContent/Engines/ConPVP/Ruleset.cs | 3 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 9 +----- .../Engines/ML Quests/Gumps/RaceChangeGump.cs | 3 +- .../Engines/Pathing/Cache/StepCache.cs | 8 +---- .../PlayerMurderSystem.cs | 21 +++++--------- .../UOContent/Engines/Virtues/VirtueSystem.cs | 9 +----- .../UOContent/Items/Misc/ProjectedItem.cs | 8 +---- Projects/UOContent/Misc/StaminaSystem.cs | 29 +++---------------- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 9 +----- .../Network/AutoDenylist/AutoDenylist.cs | 13 +++------ .../Network/Blocklist/PromotedGuard.cs | 8 ++--- .../Network/LoginAllowlist/LoginAllowlist.cs | 23 ++++----------- Projects/UOContent/Skills/AntiMacroSystem.cs | 17 ++--------- Projects/UOContent/Skills/DetectHidden.cs | 9 +----- 19 files changed, 45 insertions(+), 183 deletions(-) diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index f3786d834..9d7cd9f3b 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -1050,28 +1050,16 @@ public static partial class Utility return; } - using var queue = PooledRefQueue.Create(); foreach (var (key, value) in dictionary) { - if (serializableKey) - { - if (key == null || ((ISerializable)key).Deleted) - { - queue.Enqueue(key); - } - } - else - { - if (value == null || ((ISerializable)value).Deleted) - { - queue.Enqueue(key); - } - } - } + var deleted = serializableKey + ? ((ISerializable)key).Deleted + : value == null || ((ISerializable)value).Deleted; - while (queue.Count > 0) - { - dictionary.Remove(queue.Dequeue()); + if (deleted) + { + dictionary.Remove(key); + } } dictionary.TrimExcess(); diff --git a/Projects/UOContent/Commands/VisibilityList.cs b/Projects/UOContent/Commands/VisibilityList.cs index 3e4a0dc2a..ddad258c2 100644 --- a/Projects/UOContent/Commands/VisibilityList.cs +++ b/Projects/UOContent/Commands/VisibilityList.cs @@ -103,9 +103,8 @@ namespace Server.Commands { var list = pm.VisibilityList; - if (list.Contains(targ)) + if (list.Remove(targ)) { - list.Remove(targ); pm.SendMessage($"{targ.Name} has been removed from your visibility list."); } else diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index d82bfaf15..6160c4595 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -167,20 +167,13 @@ public class ChampionTitleSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - foreach (var context in _championTitleContexts.Values) { if (!context.CheckAtrophy()) { - queue.Enqueue(context.Player); + _championTitleContexts.Remove(context.Player); } } - - while (queue.Count > 0) - { - _championTitleContexts.Remove((PlayerMobile)queue.Dequeue()); - } } } } diff --git a/Projects/UOContent/Engines/Chat/Channel.cs b/Projects/UOContent/Engines/Chat/Channel.cs index 09b8f6cb3..69f33ddcf 100644 --- a/Projects/UOContent/Engines/Chat/Channel.cs +++ b/Projects/UOContent/Engines/Chat/Channel.cs @@ -146,15 +146,8 @@ namespace Server.Engines.Chat m_Users.Remove(user); user.CurrentChannel = null; - if (m_Moderators.Contains(user)) - { - m_Moderators.Remove(user); - } - - if (m_Voices.Contains(user)) - { - m_Voices.Remove(user); - } + m_Moderators.Remove(user); + m_Voices.Remove(user); SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username); ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel); @@ -183,10 +176,7 @@ namespace Server.Engines.Chat public void RemoveBan(ChatUser user) { - if (m_Banned.Contains(user)) - { - m_Banned.Remove(user); - } + m_Banned.Remove(user); } public void Kick(ChatUser user, ChatUser moderator = null) diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index 29e0b9949..f32168cbf 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -716,10 +716,7 @@ public partial class BRBomb : Item m.Target = new BombTarget(this, m); - if (m_Helpers.Contains(m)) - { - m_Helpers.Remove(m); - } + m_Helpers.Remove(m); if (m_Helpers.Count > 0) { diff --git a/Projects/UOContent/Engines/ConPVP/Ruleset.cs b/Projects/UOContent/Engines/ConPVP/Ruleset.cs index fb4a1ec8b..895496c43 100644 --- a/Projects/UOContent/Engines/ConPVP/Ruleset.cs +++ b/Projects/UOContent/Engines/ConPVP/Ruleset.cs @@ -56,12 +56,11 @@ namespace Server.Engines.ConPVP public void RemoveFlavor(Ruleset flavor) { - if (!Flavors.Contains(flavor)) + if (!Flavors.Remove(flavor)) { return; } - Flavors.Remove(flavor); Options.And(flavor.Options.Not()); flavor.Options.Not(); } diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index a5872e437..4548522b4 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -550,21 +550,14 @@ namespace Server.Items return; } - using var toDelete = PooledRefQueue.Create(); - foreach (var (key, value) in _guesses) { if (Core.Now - value.When > CleanupTime) { - toDelete.Enqueue(key); + _guesses.Remove(key); } } - while (toDelete.Count > 0) - { - _guesses.Remove(toDelete.Dequeue()); - } - if (_guesses.Count == 0) { _guesses = null; diff --git a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs index 6e37f88f5..a0de2461d 100644 --- a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs +++ b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs @@ -116,10 +116,9 @@ namespace Server.Engines.MLQuests.Gumps private static void CloseCurrent(NetState ns) { - if (m_Pending.TryGetValue(ns, out var state)) + if (m_Pending.Remove(ns, out var state)) { state._timeoutToken.Cancel(); - m_Pending.Remove(ns); } ns.SendCloseRaceChanger(); diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index adf178927..b4c5eba7c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -727,20 +727,14 @@ public sealed class StepCache var window = MissPromotionWindowMs; var beforeCount = _chunkMissTracker.Count; - using var toRemove = PooledRefQueue.Create(); foreach (var kvp in _chunkMissTracker) { if (now - kvp.Value.LastMissTickStamp > window) { - toRemove.Enqueue(kvp.Key); + _chunkMissTracker.Remove(kvp.Key); } } - while (toRemove.Count > 0) - { - _chunkMissTracker.Remove(toRemove.Dequeue()); - } - if (_chunkMissTracker.Count == beforeCount) { _chunkMissTracker.Clear(); diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index b5a1b77d5..d1be82aa9 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -403,27 +403,20 @@ public class PlayerMurderSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - foreach (var context in _contextTerms) { context.DecayKills(); if (!context.CheckStart()) { - queue.Enqueue(context.Player); - } - } - - while (queue.Count > 0) - { - var pm = (PlayerMobile)queue.Dequeue(); - if (_murderContexts.TryGetValue(pm, out var ctx)) - { - if (ctx.CanRemove()) + var pm = context.Player; + if (_murderContexts.TryGetValue(pm, out var ctx)) { - _murderContexts.Remove(pm); + if (ctx.CanRemove()) + { + _murderContexts.Remove(pm); + } + _contextTerms.Remove(ctx); } - _contextTerms.Remove(ctx); } } } diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index 89c5fa3b5..e57a2d386 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -372,8 +372,6 @@ public class VirtueSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - // This is not particularly efficient. If it gets too slow, then use a different architecture. foreach (var (player, virtues) in _playerVirtues) { @@ -381,14 +379,9 @@ public class VirtueSystem : GenericPersistence if (!virtues.IsUsed()) { - queue.Enqueue(player); + _playerVirtues.Remove(player); } } - - while (queue.Count > 0) - { - _playerVirtues.Remove((PlayerMobile)queue.Dequeue()); - } } ~VirtueTimer() diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index e86025613..03e93d0ea 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -126,20 +126,14 @@ public partial class ProjectedItem : Item private static void OnTick() { - using var queue = PooledRefQueue.Create(); foreach (var item in _active) { if (!item.SendEffect()) { - queue.Enqueue(item); + _active.Remove(item); } } - while (queue.Count > 0) - { - _active.Remove(queue.Dequeue() as ProjectedItem); - } - if (_active.Count == 0) { _timer?.Stop(); diff --git a/Projects/UOContent/Misc/StaminaSystem.cs b/Projects/UOContent/Misc/StaminaSystem.cs index ed8074cba..fcd5d46b0 100644 --- a/Projects/UOContent/Misc/StaminaSystem.cs +++ b/Projects/UOContent/Misc/StaminaSystem.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Mobiles; using Server.Spells.Ninjitsu; @@ -60,22 +59,16 @@ public static class StaminaSystem EventSink.Logout += Logout; // Credit idle time - using var queue = PooledRefQueue.Create(); foreach (var m in _stepsTaken.Keys) { - // We cannot remove since we are iterating. + // Keeps the ref valid for the check below. ref var stepsTaken = ref RegenSteps(m, out var exists, removeOnInvalidation: false); if (exists && stepsTaken.Steps <= 0) { - queue.Enqueue(m); + _stepsTaken.Remove(m); } } - - while (queue.Count > 0) - { - _stepsTaken.Remove(queue.Dequeue()); - } } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] @@ -325,7 +318,7 @@ public static class StaminaSystem { var from = e.Mobile; var running = (e.Direction & Direction.Running) != 0; - + if (CannotWalkWhenFatigued && from.Stam <= 0) { from.SendLocalizedMessage(500110); // You are too fatigued to move. @@ -481,27 +474,13 @@ public static class StaminaSystem if (_resetHash.Count > 0) { - using var queue = PooledRefQueue.Create(); - ref var stepsTaken = ref Unsafe.NullRef(); foreach (var m in _resetHash) { stepsTaken = ref GetStepsTaken(m, out var exists); if (!exists || Core.Now >= stepsTaken.IdleStartTime + ResetDuration) { - queue.Enqueue(m); - } - } - - if (_resetHash.Count == queue.Count) - { - _resetHash.Clear(); - } - else - { - while (queue.Count > 0) - { - _resetHash.Remove(queue.Dequeue()); + _resetHash.Remove(m); } } } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index af6c00038..08a745296 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -27,20 +27,13 @@ public abstract partial class BaseAI private static void CleanupReservedPositions() { - using var toRemove = PooledRefQueue.Create(); - foreach (var (m, p) in _reservedPositions) { if (m?.Deleted != false || m.GetDistanceToSqrt(p) < 1) { - toRemove.Enqueue(m); + _reservedPositions.Remove(m); } } - - while (toRemove.Count > 0) - { - _reservedPositions.Remove(toRemove.Dequeue()); - } } private bool UseGroupMovement(Mobile target) => diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs index a55280e1a..3ed36cfe5 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -17,7 +17,6 @@ using System; using System.Collections.Generic; using System.Net; using System.Threading; -using Server.Collections; using Server.Logging; using Server.Network.Bans; @@ -134,22 +133,18 @@ public static class AutoDenylist return; } - using var lapsed = new PooledRefList(16); + var lapsed = 0; foreach (var (address, expires) in _held) { if (expires - nowTicks <= 0) { - lapsed.Add(address); + _held.Remove(address); + lapsed++; } } - for (var i = 0; i < lapsed.Count; i++) - { - _held.Remove(lapsed[i]); - } - - if (lapsed.Count > 0) + if (lapsed > 0) { _warnedFull = false; } diff --git a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs index 4f820b16e..bba8e3ee5 100644 --- a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs +++ b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs @@ -39,17 +39,13 @@ public sealed class PromotedGuard { return; } - using var dead = Collections.PooledRefQueue.Create(); + foreach (var (ip, exp) in _expiry) { if (exp - nowTicks <= 0) { - dead.Enqueue(ip); + _expiry.Remove(ip); } } - while (dead.Count > 0) - { - _expiry.Remove(dead.Dequeue()); - } } } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs index ed65b5ec6..c44ceb33d 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -20,7 +20,6 @@ using System.IO; using System.Net; using System.Text; using System.Threading.Tasks; -using Server.Collections; using Server.Logging; using Server.Network.Bans; @@ -230,13 +229,15 @@ public static class LoginAllowlist var stamps = new long[_allowed.Count]; var count = 0; - using var expired = new PooledRefList(16); + var dropped = 0; foreach (var (address, stamp) in _allowed) { if (stamp < cutoff) { - expired.Add(address); + _allowed.Remove(address); + _strikes.Remove(address); + dropped++; continue; } @@ -245,19 +246,12 @@ public static class LoginAllowlist count++; } - for (var i = 0; i < expired.Count; i++) - { - _allowed.Remove(expired[i]); - _strikes.Remove(expired[i]); - } - PruneStaleStrikes(nowUnix); _dirty = false; var path = _path; var total = count; - var dropped = expired.Count; _ = Task.Run(() => Write(path, addresses, stamps, total, dropped)); } @@ -270,20 +264,13 @@ public static class LoginAllowlist return; } - using var stale = new PooledRefList(16); - foreach (var (address, strike) in _strikes) { if (nowUnix - strike.WindowStart > _strikeWindowSeconds) { - stale.Add(address); + _strikes.Remove(address); } } - - for (var i = 0; i < stale.Count; i++) - { - _strikes.Remove(stale[i]); - } } private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index 60fcc899f..e37431ddd 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -120,23 +120,17 @@ public static class AntiMacroSystem var now = Core.Now; - using var toRemove = PooledRefQueue.Create(); foreach (var (m, antiMacro) in _antiMacroTable) { if (antiMacro._lastExpiration <= now) { - toRemove.Enqueue(m); + _antiMacroTable.Remove(m); } else { antiMacro.CleanExpired(); } } - - while (toRemove.Count > 0) - { - _antiMacroTable.Remove(toRemove.Dequeue()); - } } [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] @@ -259,20 +253,13 @@ public static class AntiMacroSystem { var now = Core.Now; - using var toRemove = PooledRefQueue<(Skill, object)>.Create(); - foreach (var (key, countAndTimeStamp) in _antiMacroTracking) { if (countAndTimeStamp._count <= 0 || countAndTimeStamp._expiration <= now) { - toRemove.Enqueue(key); + _antiMacroTracking.Remove(key); } } - - while (toRemove.Count > 0) - { - _antiMacroTracking.Remove(toRemove.Dequeue()); - } } } diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index b1b0a02fb..487eda693 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -39,20 +39,13 @@ public static class DetectHidden // Clean up old debounce entries to prevent memory bloat private static void CleanupDebounceCache(long now) { - using var entriesToRemove = PooledRefQueue<(Mobile, Mobile)>.Create(); - foreach (var entry in PassiveDetectDebounce) { if (now - entry.Value > DebounceExpiryMs) { - entriesToRemove.Enqueue(entry.Key); + PassiveDetectDebounce.Remove(entry.Key); } } - - while (entriesToRemove.Count > 0) - { - PassiveDetectDebounce.Remove(entriesToRemove.Dequeue()); - } } // For testing: clear the debounce cache to prevent cross-test contamination From a7e65aab015e9479078e9a3fa5dd2c1f07705d15 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:13:34 -0700 Subject: [PATCH 34/64] perf(login): run password hashing on a parked worker thread (#2566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why An Argon2 verify is **~8.9 ms of frozen world per login attempt** — more than half a 16 ms frame. Failed attempts cost exactly the same as successful ones, by design, so a credential-stuffing flood is a full-cost stall per packet without needing valid credentials. `SetPassword` derives a hash too, so `[password`, the admin gump and account creation each pay the same. ## What the measurement says Off-loading does not delete the cost, it relocates it. Three things stay on the loop: | Component | Measured | |---|---:| | Inline verify (today) | **8.92 ms** | | Dispatch to the worker | 210 ns | | Drain the continuation off `LoopContext` | 13 ns | | Loop's own work slowed by shared-L3 eviction | **0.05 – 5.44 ms** | Net gain **3.5 – 8.9 ms** of on-loop time per login. Harness in `ModernUO-Benchmarks` (`Benchmarks/Argon2OffLoop/`): it models the loop as a dependent-load pointer chase swept across working-set sizes, which is an upper bound on cache-latency sensitivity, and copies `EventLoopContext` so the hand-off cost is the real one. Two results shaped the design: - **The contention tax peaks in the middle of the working-set range**, not at the top — 5.44 ms at 8 MiB (a quarter of this chip's L3), but 0.76 ms at 30 MiB and 0.10 ms at 256 KiB. A tiny hot set has nothing in L3 to lose; a huge one is already DRAM-bound. - **Per-login tax falls as concurrency rises** (5.44 → 2.56 → 1.60 ms at 1/2/4 hashers) while *total* loop damage rises. Contention is shared, not additive, so a login rush is not the disaster case — a single login is. ## Why exactly one worker It is load-bearing three times over, which is also why it must not quietly become a pool: - **Cost bound.** Off-loop loses to inline only if a hash steals ~82% of the loop's throughput. One hasher contending for one core leaves the loop ~50%. **A single background hasher cannot cost the loop more than the inline verify under any scheduling regime**, which is what lets the measurement hold on hardware we cannot inspect — AMD, VPS, oversubscribed VM. Four hashers drop the loop to ~20% and break it. - **Memory.** Exactly one hashing arena is live at a time whatever the login volume. - **Ordering.** Writes apply in dispatch order *only* because a single thread drains FIFO. A second worker would need ordering reintroduced; `WritesApplyInDispatchOrder` fails if that happens. Throughput is ~110 verifies/sec. Only loop time matters, not login latency, so head-of-line blocking during a rush costs nothing. ## Making every protection safe off-thread The worker was initially Argon2-only. That was the right call for the wrong reason — it was blamed on Argon2's salt RNG, which is a stateless syscall wrapper and was never a problem. The real blockers were elsewhere, and both are fixed at the source: | Protection | Was | Now | |---|---|---| | MD5/SHA1/SHA2 | shared `HashAlgorithm.ComputeHash`, which carries the running digest across `HashCore`/`HashFinal` through process-wide singletons | static `HashData` into a `stackalloc` span — no state, no allocation, identical bytes | | PBKDF2 | `Utility.RandomMinMax` → shared `System.Random`, thread-unsafe *and* game state | `RandomNumberGenerator.GetInt32`, matching the salt beside it | | Argon2 | already safe (`Verify` is static + stackalloc) | unchanged, singleton reused | Literal digests are pinned in a test **before** the change and still pass after it. These are compared as strings against every account database, so any casing or encoding drift would lock out every SHA and MD5 account at once. With all three safe, the worker no longer knows which algorithm it runs and the dispatch conditions collapse to "is off-loop available". ## Correctness - **Phrase derivation** moves to `AccountSecurity.DerivePhrase`, so verification (stored algorithm's rule) and rehash (target algorithm's rule) cannot disagree. Deriving with the wrong one is the shape of the lockout fixed in #2562. - **Liveness** is checked at dequeue *and* at apply — a connection can drop while queued or while the result sits in the loop queue. A job with no connection attached, such as an admin password change, runs regardless. - **Queue overflow rejects** a login rather than verifying inline; steering work back onto the loop is what a flood wants. A password change instead falls back to hashing inline, because unlike a login it must not be dropped. - **Shutdown and crash** both just stop the thread, and pending jobs are dropped. No save is initiated once shutdown begins — saving is the operator's choice up front, via the admin gump's save/no-save variants, and `WaitForWriteCompletion` honours one already in flight — so a write applied during teardown would reach no disk. The crash path needs its own subscription because `HandleClosed` skips `InvokeShutdown` when crashed. ## Bounding `MaxPending` is 4096 — a backstop, not a flood defense. `SentFirstPacket` holds a connection to one pending verify and the engine caps connections at 4096, so the queue is already bounded by construction and this can only trip if that invariant breaks. A cap low enough to blunt an attack would reject real players first; during a mass reconnect they *are* the queue. Flood defense belongs at the connection layer. The real DoS improvement is elsewhere: today every attempt stalls the world, and after this a flood occupies one core while the loop keeps ticking. ## Gate Release builds on 4+ cores. Below that there is no spare core to move work to, so off-loading buys nothing by construction; `DEBUG` is excluded because dev boxes and test shards have few logins. Both modes call the same code — the gate only chooses where it runs. ## Engine change One property, `AccountLoginEventArgs.Deferred`, so a subscriber can say "no verdict yet". `EventSink.AccountLogin` is `Action<...>` with no continuation, and the packet handler replies in the same call. Approved separately since it touches `Projects/Server/`. ## Docs `dev-docs/threading-model.md` and the threading skill gain a vetted-workers section. The forbidden-patterns table bans `new Thread`, `ConcurrentQueue`, `Interlocked` and `volatile` in `UOContent`, and its exceptions covered only `Projects/Server/` — the existing Advanced Search fan-out already sat outside it. The new section leads with proving the need (measure on-loop time, not wall-clock; gate on core count; record the measurement), keeps game logic on the loop via chunking, and documents the hand-off protocol in both directions. ## Testing 698 UOContent tests, 810 Server tests, Release build clean. Covered: verify and rehash outcomes, phrase rules for SHA1/SHA2 vs Argon2, stored-format stability for MD5/SHA1/SHA2, jobs with no connection attached, and dispatch ordering through the real queue. The liveness and ordering guards are mutation-verified. --- CLAUDE.md | 2 +- Projects/Server/Events/AccountLoginEvent.cs | 7 + .../Tests/Accounting/PasswordWorkerTests.cs | 229 ++++++++++++++ .../Security/PasswordProtectionTest.cs | 26 ++ Projects/UOContent/Accounting/Account.cs | 45 ++- .../UOContent/Accounting/AccountHandler.cs | 138 ++++++++- .../Accounting/Security/AccountSecurity.cs | 11 + .../HashAlgorithmPasswordProtection.cs | 40 ++- .../Security/PBKDF2PasswordProtection.cs | 27 +- .../Accounting/Security/PasswordWorker.cs | 293 ++++++++++++++++++ .../AdvancedSearchUtilities.cs | 1 - .../UOContent/Engines/Pathing/MovementPath.cs | 2 - Projects/UOContent/Gumps/AdminGump.cs | 3 +- .../Network/AutoDenylist/AutoDenylist.cs | 2 +- .../LoginAllowlistConfiguration.cs | 2 +- .../Network/Packets/IncomingAccountPackets.cs | 19 +- Projects/UOContent/Utilities/Types.cs | 1 - dev-docs/claude-skills/modernuo-threading.md | 72 +++++ dev-docs/threading-model.md | 132 ++++++++ 19 files changed, 1000 insertions(+), 52 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs create mode 100644 Projects/UOContent/Accounting/Security/PasswordWorker.cs diff --git a/CLAUDE.md b/CLAUDE.md index 924255249..b1a90f6ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct 9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` -10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` +10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code 13. **No empty gumps** — every gump must produce visual elements. An empty gump leaks on client+server (no way to close it). Use static `DisplayTo()` to validate before constructing → `dev-docs/gump-system.md` diff --git a/Projects/Server/Events/AccountLoginEvent.cs b/Projects/Server/Events/AccountLoginEvent.cs index 5c81af000..cf1c0bd1a 100644 --- a/Projects/Server/Events/AccountLoginEvent.cs +++ b/Projects/Server/Events/AccountLoginEvent.cs @@ -37,6 +37,13 @@ public class AccountLoginEventArgs public bool Accepted { get; set; } public ALRReason RejectReason { get; set; } + + /// + /// No verdict yet: a subscriber moved the password check off the game loop and replies itself + /// once it lands. The packet handler must send neither accept nor reject while this is set, or + /// the client gets two answers to one login. + /// + public bool Deferred { get; set; } } public static partial class EventSink diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs new file mode 100644 index 000000000..5972e5eb9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Threading; +using Server.Accounting; +using Server.Accounting.Security; +using Xunit; + +namespace Server.Tests.Accounting; + +[Collection("Sequential UOContent Tests")] +public class PasswordWorkerTests : IDisposable +{ + private const string Password = "hunter2"; + + private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + + public PasswordWorkerTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + + public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + + private static Account CreateAccount(string username) => + Accounts.GetAccount(username) as Account ?? new Account(username, Password); + + /// + /// Enqueues work, then pumps the loop context until or the deadline. + /// + /// The context pins itself to the thread that constructed it and refuses ExecuteTasks + /// from any other. The fixture's belongs to whichever thread built the fixture, and xUnit gives + /// no guarantee that a test method runs on that thread even inside a sequential collection -- + /// so this owns one for the duration and puts the original back. Pumping the fixture's context + /// passed locally and failed on CI. + /// + private static void PumpUntil(Action enqueue, Func complete, int timeoutSeconds = 20) + { + var original = Core.LoopContext; + var owned = new EventLoopContext(); + Core.LoopContext = owned; + + try + { + enqueue(); + + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (!complete() && DateTime.UtcNow < deadline) + { + owned.ExecuteTasks(); + Thread.Sleep(5); + } + + // Anything that landed between the last pump and the final check. + owned.ExecuteTasks(); + } + finally + { + Core.LoopContext = original; + } + } + + private static PasswordJob JobFor(Account account, string submitted) => + new() + { + Account = account, + StoredHash = account.Password, + VerifyPhrase = account.GetVerifyPhrase(submitted), + HashPhrase = account.NeedsPasswordUpgrade() ? account.GetRehashPhrase(submitted) : null, + StoredAlgorithm = account.PasswordAlgorithm, + TargetAlgorithm = AccountSecurity.CurrentAlgorithm + }; + + /// + /// Drives the real queue rather than ComputeInline. A job with no NetState attached -- an + /// admin password change -- was being dropped by the liveness check, which read a null State as + /// a dead connection, so the change silently never happened and its callback never fired. + /// + [Fact] + public void RunsAJobThatHasNoConnectionAttached() + { + var account = CreateAccount("offloop-no-netstate-user"); + var applied = false; + + var job = new PasswordJob + { + Account = account, + HashPhrase = account.GetRehashPhrase("a-queued-password"), + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = (_, outcome) => applied = outcome.Hash != null + }; + + PumpUntil(() => Assert.True(PasswordWorker.TryEnqueue(job)), () => applied); + + Assert.True(applied); + Assert.True(account.CheckPassword("a-queued-password")); + } + + [Fact] + public void VerifiesTheCorrectPassword() + { + var account = CreateAccount("offloop-correct-user"); + + var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); + + Assert.True(outcome.Verified); + } + + [Fact] + public void RejectsTheWrongPassword() + { + var account = CreateAccount("offloop-wrong-user"); + + var outcome = PasswordWorker.ComputeInline(JobFor(account, "not-the-password")); + + Assert.False(outcome.Verified); + Assert.Null(outcome.Hash); + } + + [Fact] + public void ProducesNoUpgradeWhenParametersAreCurrent() + { + var account = CreateAccount("offloop-current-user"); + + var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); + + Assert.True(outcome.Verified); + Assert.Null(outcome.Hash); + } + + [Fact] + public void ProducesAnUpgradeWhenParametersAreStale() + { + var account = CreateAccount("offloop-stale-user"); + + // The shipping default before #2562: Argon2i, m=8192, t=3, p=1. + account.Password = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); + + Assert.True(outcome.Verified); + Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", outcome.Hash); + } + + [Fact] + public void ProducesNoUpgradeWhenThePasswordIsWrong() + { + var account = CreateAccount("offloop-wrong-stale-user"); + account.Password = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + var outcome = PasswordWorker.ComputeInline(JobFor(account, "not-the-password")); + + Assert.False(outcome.Verified); + Assert.Null(outcome.Hash); + } + + [Fact] + public void AppliesAWrite() + { + var account = CreateAccount("offloop-apply-user"); + var upgraded = Argon2PasswordProtection.Instance.EncryptPassword(Password); + + account.ApplyPasswordWrite(upgraded, PasswordProtectionAlgorithm.Argon2); + + Assert.Equal(upgraded, account.Password); + Assert.True(account.CheckPassword(Password)); + } + + /// + /// Writes apply in dispatch order, which is what makes a guard unnecessary: dispatch is on the + /// loop, one worker drains FIFO, and results return through the loop context in that same order. + /// A second worker thread would break this and would need ordering reintroduced. + /// + [Fact] + public void WritesApplyInDispatchOrder() + { + var account = CreateAccount("offloop-two-writes-user"); + var done = 0; + + PumpUntil( + () => + { + for (var i = 1; i <= 2; i++) + { + Assert.True( + PasswordWorker.TryEnqueue( + new PasswordJob + { + Account = account, + HashPhrase = account.GetRehashPhrase($"password-{i}"), + StoredAlgorithm = account.PasswordAlgorithm, + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = (_, _) => done++ + } + ) + ); + } + }, + () => done >= 2 + ); + + Assert.Equal(2, done); + Assert.True(account.CheckPassword("password-2")); + Assert.False(account.CheckPassword("password-1")); + } + + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + public void UsesTheUsernameSaltedPhraseForShaAccounts(PasswordProtectionAlgorithm algorithm) + { + AccountSecurity.CurrentAlgorithm = algorithm; + var account = CreateAccount($"offloop-phrase-{algorithm}-user"); + + // Verification must use the algorithm the hash was stored under... + Assert.Equal($"{account.Username}{Password}", account.GetVerifyPhrase(Password)); + + // ...and a rehash the one it is moving to. Swapping these is the #2562 lockout. + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + Assert.Equal(Password, account.GetRehashPhrase(Password)); + } + + [Fact] + public void UsesTheBarePasswordForArgon2Accounts() + { + var account = CreateAccount("offloop-phrase-argon2-user"); + + Assert.Equal(Password, account.GetVerifyPhrase(Password)); + Assert.Equal(Password, account.GetRehashPhrase(Password)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index b86df418d..a0ef97a56 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -75,6 +75,32 @@ public class PasswordProtectionTest Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); } + /// + /// Literal digests of , so the stored format cannot drift. These are + /// compared as strings against what is already in every account database -- a casing or encoding + /// change would lock out every SHA and MD5 account on the shard at once. + /// + [Theory] + [InlineData("MD5", "52284053181040AC90DBDE74A0E7FF5E")] + [InlineData("SHA1", "9AC635509803AAE2D8312BA1879289259A50C5F0")] + [InlineData( + "SHA2", + "5A727BFF8F8E08A24BDF6B0CD5065F30A1F8E0060B857BB8AFD6955BE0ACBC489DA63F19B8F4CF08D73DE4069CF4B" + + "29D94B353F31513B2FB2D9382EFE15AE975" + )] + public void HashAlgorithm_StoredFormatIsStable(string algorithmType, string expected) + { + var protection = algorithmType switch + { + "SHA1" => HashAlgorithmPasswordProtection.SHA1Instance, + "SHA2" => HashAlgorithmPasswordProtection.SHA2Instance, + _ => HashAlgorithmPasswordProtection.MD5Instance, + }; + + Assert.Equal(expected, protection.EncryptPassword(plainPassword)); + Assert.True(protection.ValidatePassword(expected, plainPassword)); + } + // The shipping default before this change, as a literal so it cannot drift with the configured // defaults. Password: "hunter2". private const string LegacyArgon2iHash = diff --git a/Projects/UOContent/Accounting/Account.cs b/Projects/UOContent/Accounting/Account.cs index 45ede29bb..b583585ee 100644 --- a/Projects/UOContent/Accounting/Account.cs +++ b/Projects/UOContent/Accounting/Account.cs @@ -379,28 +379,53 @@ public partial class Account : IAccount, IComparable public void SetPassword(string plainPassword) { PasswordAlgorithm = AccountSecurity.CurrentAlgorithm; - var phrase = PasswordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{_username}{plainPassword}" - : plainPassword; + Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword( + AccountSecurity.DerivePhrase(PasswordAlgorithm, _username, plainPassword) + ); + } - Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase); + /// The phrase that verifies against the currently stored hash. + internal string GetVerifyPhrase(string plainPassword) => + AccountSecurity.DerivePhrase(_passwordAlgorithm, _username, plainPassword); + + /// The phrase a rehash to the configured algorithm would be derived from. + internal string GetRehashPhrase(string plainPassword) => + AccountSecurity.DerivePhrase(AccountSecurity.CurrentAlgorithm, _username, plainPassword); + + /// + /// Whether a successful login should rewrite the stored hash, because the algorithm changed or + /// its cost parameters moved. + /// + internal bool NeedsPasswordUpgrade() => + _passwordAlgorithm != AccountSecurity.CurrentAlgorithm || + AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password); + + /// + /// Applies a hash derived off the game loop. Distinct from the private UpgradePassword + /// below, which adopts a legacy hash when loading pre-binary XML accounts. + /// + /// Unguarded: dispatch is on the loop, one worker drains FIFO, and results return through the + /// loop context in that order, so last dispatched is last applied. A second worker would need + /// ordering reintroduced here. + /// + internal void ApplyPasswordWrite(string newEncrypted, PasswordProtectionAlgorithm algorithm) + { + PasswordAlgorithm = algorithm; + Password = newEncrypted; } public bool CheckPassword(string plainPassword) { - var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{_username}{plainPassword}" - : plainPassword; + var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm) + .ValidatePassword(Password, GetVerifyPhrase(plainPassword)); - var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase); if (!ok) { return false; } // Upgrade the password protection in case we change the algorithm - if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm || - AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password)) + if (NeedsPasswordUpgrade()) { SetPassword(plainPassword); } diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs index 542e16a40..eea0dc069 100644 --- a/Projects/UOContent/Accounting/AccountHandler.cs +++ b/Projects/UOContent/Accounting/AccountHandler.cs @@ -5,6 +5,7 @@ using System.Net; using System.Runtime.CompilerServices; using ModernUO.CodeGeneratedEvents; using Server.Accounting; +using Server.Accounting.Security; using Server.Engines.CharacterCreation; using Server.Engines.Help; using Server.Logging; @@ -69,6 +70,9 @@ public static class AccountHandler public static void Initialize() { EventSink.AccountLogin += EventSink_AccountLogin; + + EventSink.Shutdown += PasswordWorker.Stop; + EventSink.ServerCrashed += PasswordWorker.OnCrashed; } [Usage("Password ")] @@ -139,8 +143,12 @@ public static class AccountHandler if (accessList[0].MatchClassC(ipAddress)) { - acct.SetPassword(pass); - from.SendMessage("The password to your account has changed."); + // Confirmed from the callback: off-loop the write has not landed yet here. + PasswordWorker.SetPassword( + acct, + pass, + _ => from.SendMessage("The password to your account has changed.") + ); } else { @@ -307,25 +315,129 @@ public static class AccountHandler logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass; } - else if (!acct.CheckPassword(pw)) + else { - logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); - e.RejectReason = ALRReason.BadPass; + HandlePasswordCheck(e, acct, pw); } - else if (acct.Banned) + } + + /// + /// Separate from the caller's else-if chain because two outcomes are not verdicts: the off-loop + /// path has none yet, and a full queue must reject rather than fall through and verify. + /// + private static void HandlePasswordCheck(AccountLoginEventArgs e, Account acct, string pw) + { + switch (DispatchPasswordCheck(e, acct, pw)) { - logger.Information("Login: {NetState} Banned account '{Username}'", e.State, un); + case PasswordCheckDispatch.Deferred: + { + e.Deferred = true; + return; + } + case PasswordCheckDispatch.Saturated: + { + // Reject rather than verify inline: steering work back onto the loop is what a + // flood wants. + logger.Warning( + "Login: {NetState} Password verification queue full, rejecting '{Username}'", + e.State, + acct.Username + ); + + e.RejectReason = ALRReason.BadComm; + return; + } + } + + if (!acct.CheckPassword(pw)) + { + logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, acct.Username); + e.RejectReason = ALRReason.BadPass; + return; + } + + ApplyVerifiedLogin(e, acct); + } + + /// Everything after the password is known good, shared so an off-loop verdict lands + /// in the same state as an inline one. + private static void ApplyVerifiedLogin(AccountLoginEventArgs e, Account acct) + { + if (acct.Banned) + { + logger.Information("Login: {NetState} Banned account '{Username}'", e.State, acct.Username); e.RejectReason = ALRReason.Blocked; + return; + } + + logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, acct.Username); + e.State.Account = acct; + e.Accepted = true; + + acct.LogAccess(e.State); + LoginAllowlist.RecordLogin(e.State?.Address); + } + + private enum PasswordCheckDispatch + { + /// Verify on the loop. + Inline, + + /// Handed to the worker; no verdict yet. + Deferred, + + /// The queue is full. + Saturated + } + + /// + /// Hands the password check to the worker, whatever algorithm it uses. Every protection is safe + /// off the loop, so there is no carve-out, and a cheap digest does not need one either: + /// AccountSecurity.Configure refuses anything below SHA2 as the configured algorithm, so + /// MD5 and SHA1 only appear as a stored hash awaiting migration. That makes + /// NeedsPasswordUpgrade true, and the upgrade hash dominates the job. + /// + private static PasswordCheckDispatch DispatchPasswordCheck(AccountLoginEventArgs e, Account acct, string pw) + { + if (!PasswordWorker.Enabled) + { + return PasswordCheckDispatch.Inline; + } + + var job = new PasswordJob + { + Account = acct, + State = e.State, + StoredHash = acct.Password, + StoredAlgorithm = acct.PasswordAlgorithm, + VerifyPhrase = acct.GetVerifyPhrase(pw), + HashPhrase = acct.NeedsPasswordUpgrade() ? acct.GetRehashPhrase(pw) : null, + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = static (j, outcome) => + CompleteDeferredAccountLogin(j.State, j.Account, outcome.Verified) + }; + + return PasswordWorker.TryEnqueue(job) + ? PasswordCheckDispatch.Deferred + : PasswordCheckDispatch.Saturated; + } + + /// Resumes a login whose password check ran on the verification thread. + internal static void CompleteDeferredAccountLogin(NetState state, Account acct, bool verified) + { + var e = new AccountLoginEventArgs(state, acct.Username, null); + + if (verified) + { + ApplyVerifiedLogin(e, acct); } else { - logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, un); - e.State.Account = acct; - e.Accepted = true; - - acct.LogAccess(e.State); - LoginAllowlist.RecordLogin(e.State?.Address); + logger.Information("Login: {NetState} Invalid password for '{Username}'", state, acct.Username); + e.RejectReason = ALRReason.BadPass; } + + IncomingAccountPackets.CompleteAccountLogin(state, e.Accepted, e.RejectReason); } [OnEvent(nameof(GameServer.GameServerLoginEvent))] diff --git a/Projects/UOContent/Accounting/Security/AccountSecurity.cs b/Projects/UOContent/Accounting/Security/AccountSecurity.cs index 9f7faeafc..deae4c8e0 100644 --- a/Projects/UOContent/Accounting/Security/AccountSecurity.cs +++ b/Projects/UOContent/Accounting/Security/AccountSecurity.cs @@ -51,6 +51,17 @@ public static class AccountSecurity } } + /// + /// The string actually fed to the KDF. SHA1 and SHA2 salt by username; everything else hashes + /// the password alone. Verification must derive with the algorithm the stored hash was made + /// with, and a rehash with the one it is moving to -- deriving with the wrong one produces a + /// hash that verifies once and never again. + /// + public static string DerivePhrase(PasswordProtectionAlgorithm algorithm, string username, string plainPassword) + => algorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 + ? $"{username}{plainPassword}" + : plainPassword; + public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm) { var passwordProtection = algorithm switch diff --git a/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs b/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs index 6d6e579cd..63d0ac7a8 100644 --- a/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs @@ -19,19 +19,47 @@ using Server.Text; namespace Server.Accounting.Security; +/// +/// The obsolete unsalted digests, kept only so imported accounts can log in once and be upgraded. +/// +/// Hashing goes through the one-shot static APIs rather than a retained . +/// A instance carries the running digest across HashCore/HashFinal, so +/// two threads sharing one corrupt each other's result -- and these are process-wide singletons. +/// The static form has no such state, allocates nothing, and produces identical bytes. +/// public class HashAlgorithmPasswordProtection : IPasswordProtection { - public static IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(MD5.Create()); - public static IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(SHA1.Create()); - public static IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(SHA512.Create()); - private readonly HashAlgorithm _hashAlgorithm; + private enum Kind + { + MD5, + SHA1, + SHA512 + } - public HashAlgorithmPasswordProtection(HashAlgorithm hashAlgorithm) => _hashAlgorithm = hashAlgorithm; + public static readonly IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(Kind.MD5); + public static readonly IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(Kind.SHA1); + public static readonly IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(Kind.SHA512); + + private const int MaxDigestLength = 64; // SHA512, the largest of the three. + + private readonly Kind _kind; + + private HashAlgorithmPasswordProtection(Kind kind) => _kind = kind; public string EncryptPassword(string plainPassword) { var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii(); - return _hashAlgorithm.ComputeHash(bytes).ToHexString(); + + Span digest = stackalloc byte[MaxDigestLength]; + + var written = _kind switch + { + Kind.MD5 => MD5.HashData(bytes, digest), + Kind.SHA1 => SHA1.HashData(bytes, digest), + _ => SHA512.HashData(bytes, digest) + }; + + return digest[..written].ToHexString(); } public bool ValidatePassword(string encryptedPassword, string plainPassword) => diff --git a/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs index 7ae464021..96706c030 100644 --- a/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs @@ -22,23 +22,24 @@ namespace Server.Accounting.Security; public class PBKDF2PasswordProtection : IPasswordProtection { - private const ushort m_MinIterations = 1024; - private const ushort m_MaxIterations = 1536; - private const int m_SaltSize = 8; - private const int m_HashSize = 32; - private const int m_OutputSize = 2 + m_SaltSize + m_HashSize; + private const ushort MinIterations = 1024; + private const ushort MaxIterations = 1536; + private const int SaltSize = 8; + private const int HashSize = 32; + private const int OutputSize = 2 + SaltSize + HashSize; public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection(); public string EncryptPassword(string plainPassword) { - Span output = stackalloc byte[m_OutputSize]; - var iterations = Utility.RandomMinMax(m_MinIterations, m_MaxIterations); + Span output = stackalloc byte[OutputSize]; + + var iterations = RandomNumberGenerator.GetInt32(MinIterations, MaxIterations + 1); BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations); - var salt = output.Slice(2, m_SaltSize); + var salt = output.Slice(2, SaltSize); RandomNumberGenerator.Fill(salt); - var hash = output.Slice(2 + m_SaltSize, m_HashSize); + var hash = output.Slice(2 + SaltSize, HashSize); Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); return output.ToHexString(); @@ -46,15 +47,15 @@ public class PBKDF2PasswordProtection : IPasswordProtection public bool ValidatePassword(string encryptedPassword, string plainPassword) { - Span encryptedBytes = stackalloc byte[m_OutputSize]; + Span encryptedBytes = stackalloc byte[OutputSize]; encryptedPassword.GetBytes(encryptedBytes); var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]); - var salt = encryptedBytes.Slice(2, m_SaltSize); + var salt = encryptedBytes.Slice(2, SaltSize); - Span hash = stackalloc byte[m_HashSize]; + Span hash = stackalloc byte[HashSize]; Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); - return hash.SequenceEqual(encryptedBytes[(m_SaltSize + 2)..]); + return hash.SequenceEqual(encryptedBytes[(SaltSize + 2)..]); } } diff --git a/Projects/UOContent/Accounting/Security/PasswordWorker.cs b/Projects/UOContent/Accounting/Security/PasswordWorker.cs new file mode 100644 index 000000000..83e9207f1 --- /dev/null +++ b/Projects/UOContent/Accounting/Security/PasswordWorker.cs @@ -0,0 +1,293 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: PasswordWorker.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.Concurrent; +using System.Threading; +using Server.Logging; +using Server.Network; + +namespace Server.Accounting.Security; + +/// +/// Work handed to the password thread, which reads no game state and writes none. +/// +/// Verify and hash are independently optional: a login verifies and may rehash, an explicit change +/// only hashes. +/// +internal sealed class PasswordJob +{ + public Account Account; + + /// Ties the job to a connection. Null when the work is not gated on one, such as a + /// password change by an admin. + public NetState State; + + /// Hash to verify against, with . + public string StoredHash; + + /// Algorithm was written with. Both algorithms are resolved on + /// the loop; AccountSecurity.CurrentAlgorithm is mutable state the worker must not read. + public PasswordProtectionAlgorithm StoredAlgorithm; + + /// Phrase to verify, or null to skip verification. + public string VerifyPhrase; + + /// Phrase to hash, or null when nothing needs writing. + public string HashPhrase; + + public PasswordProtectionAlgorithm TargetAlgorithm; + + /// Runs on the game loop with the result. Free to touch game state. + public Action OnComplete; +} + +internal readonly struct PasswordOutcome +{ + /// True when no verification was asked for, or it succeeded. + public readonly bool Verified; + + /// The derived hash, or null when nothing was hashed or verification failed. + public readonly string Hash; + + public PasswordOutcome(bool verified, string hash) + { + Verified = verified; + Hash = hash; + } +} + +/// +/// Runs password hashing off the game loop. An Argon2 verify costs ~8.9 ms of frozen world per +/// login attempt, successful or not. +/// +/// Exactly one worker, and that is load-bearing three times over. It cannot cost the loop more than +/// an inline verify under any scheduling regime, because at worst it takes an equal share of one +/// core -- which is what lets the measurement hold on hardware we cannot inspect. It caps live +/// hashing arenas at one. And writes apply in dispatch order only because a single thread drains +/// FIFO, so a second would need ordering reintroduced. +/// +/// ~110 verifies/sec, which is ample: only loop time matters, not login latency. +/// +internal sealed class PasswordWorker +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordWorker)); + + /// + /// Backstop, not a flood defense. SentFirstPacket holds a connection to one pending + /// verify and the engine caps connections at 4096 (NetState.Network.cs), so this matches + /// that bound and can only trip if that invariant breaks. A cap low enough to blunt an attack + /// would reject real players first; flood defense belongs at the connection layer. + /// + private const int MaxPending = 4096; + + // Nothing signals the worker when a save freeze ends, so it re-checks on this interval -- but + // only while a save is in progress, never in steady state. + private const int SaveGatePollMs = 50; + + private static PasswordWorker _instance; + + // Needs a spare core to move work to, which a 1-2 core host does not have. Off in DEBUG, where + // logins are rare and the inline path is easier to follow. + internal static readonly bool Enabled = +#if DEBUG + false; +#else + Environment.ProcessorCount >= 4; +#endif + + private readonly Thread _thread; + private readonly AutoResetEvent _work = new(false); + private readonly ConcurrentQueue _queue = []; + + private int _pending; + private volatile bool _exit; + + private PasswordWorker() + { + _thread = new Thread(Execute) + { + IsBackground = true, + Name = "Password Worker" + }; + + _thread.Start(); + } + + private static PasswordWorker Instance => _instance ??= new PasswordWorker(); + + /// Queues a job. False when full, and the caller must then reject without verifying. + internal static bool TryEnqueue(PasswordJob job) => Instance.TryEnqueueCore(job); + + private bool TryEnqueueCore(PasswordJob job) + { + if (Volatile.Read(ref _pending) >= MaxPending) + { + return false; + } + + Interlocked.Increment(ref _pending); + _queue.Enqueue(job); + _work.Set(); + + return true; + } + + /// + /// Checked before each job, which bounds a save overlap to whichever hash was already running: + /// the freeze holds the loop, so nothing new can be queued during it. PendingSave counts too -- + /// the serialization threads are already awake and spinning on an empty queue by then. + /// + private static bool CanRunNow() => World.WorldState is WorldState.Running or WorldState.WritingSave; + + private void Execute() + { + while (!_exit) + { + if (_queue.IsEmpty) + { + // A kernel block at zero CPU. Set() during a hash leaves the event signalled, so a + // wake arriving mid-job is not lost. + _work.WaitOne(); + continue; + } + + if (!CanRunNow()) + { + _work.WaitOne(SaveGatePollMs); + continue; + } + + if (!_queue.TryDequeue(out var job)) + { + continue; + } + + Interlocked.Decrement(ref _pending); + + // Gone while it waited: skip it rather than hash for a verdict nobody receives. Running + // only goes true -> false, so a stale read wastes a hash but never skips a live one. A + // null State is a job with no connection to lose, and still runs. + if (job.State?.Running == false) + { + continue; + } + + PasswordOutcome outcome; + + try + { + outcome = Compute(job); + } + catch (Exception ex) + { + // A verdict must still come back, or the connection never gets a reply. + logger.Error(ex, "Password work failed for {Username}", job.Account?.Username); + outcome = new PasswordOutcome(false, null); + } + + Core.LoopContext.Post(() => Apply(job, outcome)); + } + } + + private static PasswordOutcome Compute(PasswordJob job) + { + if (job.VerifyPhrase != null && + !AccountSecurity.GetPasswordProtection(job.StoredAlgorithm) + .ValidatePassword(job.StoredHash, job.VerifyPhrase)) + { + return new PasswordOutcome(false, null); + } + + return new PasswordOutcome( + true, + job.HashPhrase == null + ? null : AccountSecurity.GetPasswordProtection(job.TargetAlgorithm).EncryptPassword(job.HashPhrase) + ); + } + + private static void Apply(PasswordJob job, PasswordOutcome outcome) + { + // Re-checked: a connection can drop while the result sits in the loop queue. + if (job.State?.Running == false) + { + return; + } + + if (outcome.Verified && outcome.Hash != null) + { + job.Account.ApplyPasswordWrite(outcome.Hash, job.TargetAlgorithm); + } + + job.OnComplete?.Invoke(job, outcome); + } + + /// + /// Sets a password, off the loop where available and inline otherwise, invoking + /// on the loop either way. + /// + /// Confirm from , not the call site: off-loop the write has not + /// happened when this returns. + /// + internal static void SetPassword(Account account, string plainPassword, Action onDone) + { + if (!Enabled) + { + account.SetPassword(plainPassword); + onDone?.Invoke(true); + return; + } + + var job = new PasswordJob + { + Account = account, + HashPhrase = account.GetRehashPhrase(plainPassword), + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = (_, outcome) => onDone?.Invoke(outcome.Hash != null) + }; + + if (!TryEnqueue(job)) + { + // Saturated. Unlike a login, a password change must not be dropped, so it pays the + // hash on the loop instead. + account.SetPassword(plainPassword); + onDone?.Invoke(true); + } + } + + /// Runs a job on the calling thread. The seam the tests drive. + internal static PasswordOutcome ComputeInline(PasswordJob job) => Compute(job); + + /// + /// Stops the worker on shutdown or crash. Pending jobs are dropped rather than finished: + /// nothing saves the world after this point, so a write applied here would reach no disk. + /// + /// Draining the loop context is not this type's business either. That belongs in the core + /// shutdown path, before subscriber events run -- a subscriber pumping the shared context would + /// execute other subscribers' work at an arbitrary point in the event order. + /// + internal static void Stop() => _instance?.StopThread(); + + // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own + // subscription. + internal static void OnCrashed(ServerCrashedEventArgs e) => Stop(); + + private void StopThread() + { + _exit = true; + _work.Set(); + _thread.Join(TimeSpan.FromSeconds(5)); + } +} diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 2c349847d..6775dcaef 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -1,6 +1,5 @@ using System; using System.Buffers; -using System.Collections.Generic; using System.Globalization; using System.Numerics; using System.Runtime.CompilerServices; diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index 32aa8ba11..d1ef874fc 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -1,7 +1,5 @@ using System; using System.Diagnostics; -using Server.Engines.Pathing; -using Server.Engines.Pathing.Cache; using Server.Items; using Server.PathAlgorithms; using Server.Spells; diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 1fe917b55..3a48bc5aa 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -5,6 +5,7 @@ using System.Net; using System.Runtime.InteropServices; using System.Threading; using Server.Accounting; +using Server.Accounting.Security; using Server.Collections; using Server.Commands; using Server.Maps; @@ -2903,7 +2904,7 @@ namespace Server.Gumps else { notice = "The password has been changed."; - a.SetPassword(password); + PasswordWorker.SetPassword(a, password, null); page = AdminGumpPage.AccountDetails_Information; CommandLogging.WriteLine( from, diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs index 3ed36cfe5..b9b4dd7c1 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -28,7 +28,7 @@ namespace Server.Network; /// /// The local half of promotion. Contributing to CrowdSec only helps once an OS bouncer reacts; until then /// every reconnect costs a socket, a buffer and a NetState slot — and the verdicts that matter most -/// are reachable only after reading bytes, like a zero seed. It is also the whole defence on a shard running +/// are reachable only after reading bytes, like a zero seed. It is also the whole defense on a shard running /// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban /// without a ban's review. Only verdicts are held. /// diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs index b3242a85f..08f04ecf2 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs @@ -93,7 +93,7 @@ public record LoginAllowlistSettings /// this it escalates like anything else until it earns a new entry by logging in again. /// /// - /// Generous on purpose: local defences never stop applying, so a high threshold only delays the external + /// Generous on purpose: local defenses never stop applying, so a high threshold only delays the external /// ban. A bad line might trip a gate a few times an hour; a host being used to flood burns through this /// in seconds. Set to 0 to never revoke. /// diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 888eeb6d7..792ed880b 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -564,7 +564,22 @@ public static class IncomingAccountPackets EventSink.InvokeAccountLogin(accountLoginEventArgs); - if (accountLoginEventArgs.Accepted) + // The password check moved off the loop; whoever took it replies when the verdict lands. + if (accountLoginEventArgs.Deferred) + { + return; + } + + CompleteAccountLogin(state, accountLoginEventArgs.Accepted, accountLoginEventArgs.RejectReason); + } + + /// + /// Replies to an account login. Split out so a verdict produced off the loop reaches the client + /// through exactly the same path as one produced inline. + /// + internal static void CompleteAccountLogin(NetState state, bool accepted, ALRReason rejectReason) + { + if (accepted) { var serverListEventArgs = new GatewayServer.ServerListEventArgs(state, state.Account); @@ -584,7 +599,7 @@ public static class IncomingAccountPackets else { state.Account = null; - AccountLogin_ReplyRej(state, accountLoginEventArgs.RejectReason); + AccountLogin_ReplyRej(state, rejectReason); } } diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index fa669d96a..ca5b9422d 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; diff --git a/dev-docs/claude-skills/modernuo-threading.md b/dev-docs/claude-skills/modernuo-threading.md index 800bf77d2..3b6507af2 100644 --- a/dev-docs/claude-skills/modernuo-threading.md +++ b/dev-docs/claude-skills/modernuo-threading.md @@ -150,6 +150,78 @@ These files MAY use threading (they're server infrastructure, not game logic): - `Projects/Server/Network/` - Network I/O - `Projects/Server/Timer/Timer.Pool.cs` - Pool refill +## Exceptions: Vetted Workers in UOContent + +**A background thread is a last resort.** The forbidden list is about game logic, which is never +threaded. A dedicated worker touching no game state is the sanctioned way off the loop, and +necessarily uses `new Thread`, `ConcurrentQueue`, `Interlocked`, `AutoResetEvent` and +`volatile` **at the thread boundary only**. + +### Prove the need first + +- Measure **on-loop time**, not wall-clock. Frozen world is the cost; player latency is not. +- Off-loading creates no CPU. On 1-2 cores there is no spare core — gate on `ProcessorCount`. +- Count what stays: dispatch, continuation, and the loop slowing while the worker evicts shared L3. +- Record the measurement, or nobody can re-justify the worker later. + +### Game logic stays on the loop — chunk it + +Work needing game state cannot be threaded at any core count. Too slow for one tick? Split across +ticks, bounded by count or elapsed time — never "until done". + +```csharp +Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMilliseconds(50), () => +{ + var budget = 0; + while (_cursor < _items.Count && budget++ < 100) { Process(_items[_cursor++]); } +}); +``` + +### Vetted workers + +| Worker | Justification | +|---|---| +| `Accounting/Security/PasswordWorker.cs` | 8.9 ms/login on-loop at Argon2; 3.5-8.9 ms measured saving | +| `Engines/Advanced Search/AdvancedSearchGump.cs` | Admin-triggered full-world scan, saves disabled | + +### The six rules + +1. No game state read or written off-thread; dispatch immutable values captured on the loop. +2. Resolve policy (algorithm, salt, era branch) at dispatch — the worker holds none. +3. Park on a kernel wait, never spin. Spinning burns a core on shared hosts. +4. Run only while `WorldState is Running or WritingSave`. **Not** `World.Saving` — that misses + `PendingSave`, where serialization threads are already spinning. +5. Bounded queue, or a bound upstream named in a comment. +6. Everything the worker calls must itself be thread-safe. A singleton is not automatically safe — + `HashAlgorithm.ComputeHash` carries state, `Utility`'s RNG is a shared `System.Random` and game + state. Prefer static one-shot APIs (`SHA256.HashData`, `RandomNumberGenerator.Fill`). + +### Crossing the boundary + +Dispatch captures what the continuation will need to re-validate: + +```csharp +var job = new Job { Target = state, Expected = account.Password, Input = DerivePhrase(...) }; +if (!Worker.TryEnqueue(job)) { /* reject — never fall back to running it inline */ } +``` + +Hand back one of two ways, and no other: + +```csharp +Core.LoopContext.Post(() => Apply(job, result)); // a result for a specific caller +Volatile.Write(ref _snapshot, newTable); // a shared table rebuilt periodically +``` + +The continuation re-validates, because time passed: + +```csharp +if (job.Target?.Running != true) { return; } // gone +if (account.Password != job.Expected) { return; } // changed underneath +``` + +Always post a result, including on failure — a worker that throws silently leaves its caller +waiting forever. Use `ConfigureAwait(false)` on every await inside off-loop work. + ## Anti-Patterns | Pattern | Problem | Solution | diff --git a/dev-docs/threading-model.md b/dev-docs/threading-model.md index 09b0ec8f4..d89bc1c61 100644 --- a/dev-docs/threading-model.md +++ b/dev-docs/threading-model.md @@ -130,6 +130,138 @@ These files in `Projects/Server/` MAY use threading because they handle I/O outs - `Timer/Timer.Pool.cs` -- Async pool refill - `EventLoopTasks.cs` -- The synchronization context itself +### Exceptions: Vetted Workers in `Projects/UOContent/` + +**Take great care here. A background thread is a last resort, not a tool of first choice.** + +The table above is about **game logic**, which is never threaded. A dedicated worker that touches +no game state is the sanctioned way to move CPU-heavy or I/O work off the loop, and it necessarily +uses primitives the table forbids -- `new Thread`, `ConcurrentQueue`, `Interlocked`, +`AutoResetEvent`, `volatile`. Those are legitimate **at the thread boundary**, and nowhere else. + +#### First: prove the need + +Do not add a worker because something "looks slow". Measure, and measure the right thing: + +- **Measure on-loop time, not wall-clock.** How long a player waits does not matter; how long the + world is frozen does. A change that improves latency but not loop time buys nothing. +- **Off-loading does not create CPU.** It converts "the loop is blocked for N ms" into "the loop + competes for cores for N ms". On a 1--2 core host there is no spare core and it buys nothing at + all -- gate on `Environment.ProcessorCount`. +- **Account for what stays behind.** Dispatch, the continuation, and the loop's own work slowing + down while the worker evicts shared L3. That last one is real and is usually the largest. +- **Write the benchmark down.** A worker with no recorded measurement cannot be re-justified later, + and will be removed by someone who cannot tell whether it earns its complexity. + +#### Game logic stays on the loop -- chunk it instead + +Work that **needs** game state cannot be threaded at any core count. If it is too slow for one +tick, split it across ticks rather than across threads: + +```csharp +// Bound the work per tick, resume where it left off. +Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMilliseconds(50), () => +{ + var budget = 0; + while (_cursor < _items.Count && budget++ < 100) + { + Process(_items[_cursor++]); + } +}); +``` + +Bound by count or elapsed time, never by "until done". Threading game state is not a faster +version of this -- it is a correctness bug. + +#### Vetted workers + +| Worker | Off-loop work | Justification | +|---|---|---| +| `Accounting/Security/PasswordWorker.cs` | Password verification and hashing | `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md` -- 8.9 ms/login on-loop at Argon2, measured 3.5--8.9 ms saved | +| `Engines/Advanced Search/AdvancedSearchGump.cs` | Parallel entity search | Admin-triggered full-world scan; saves disabled for its duration | + +Adding to this table needs the same bar: a measurement, and all five rules below. + +#### The six rules + +1. **No game state off-thread, read or written.** Hand the worker immutable values (strings, + structs) captured on the loop. Carrying a reference is fine only if the worker just passes it + back untouched. +2. **Decide policy on the loop, compute on the worker.** Anything rule-dependent -- which algorithm, + which salt, which era branch -- is resolved at dispatch, so the worker holds no policy it could + apply inconsistently. +3. **Park on a kernel wait; never spin.** `AutoResetEvent.WaitOne()` costs nothing while idle. + `SerializationThreadWorker` does spin, but only to await a producer mid-drain; absent that race, + spinning is a bug that burns a core on shared hosts. +4. **Yield to world saves.** Run only while `WorldState is Running or WritingSave`. `World.Saving` + is *not* the right check -- it covers only the freeze and misses `PendingSave`, where the + serialization threads are already awake and spinning on an empty queue. +5. **Bound the queue**, or rely on a bound upstream and say which one in a comment. +6. **Everything the worker calls must itself be safe off-thread.** A process-wide singleton is not + automatically safe -- look for instance state. `HashAlgorithm.ComputeHash` carries the running + digest across `HashCore`/`HashFinal`, so two threads sharing one corrupt each other. `Utility`'s + RNG is a shared `System.Random`, which is both thread-unsafe and game state. Prefer the static + one-shot forms (`SHA256.HashData`, `RandomNumberGenerator.Fill`), and if a dependency cannot be + made safe, fix it at the source rather than narrowing the worker around it. + +#### Handing work across the boundary + +**Loop → worker (dispatch).** Snapshot everything needed into immutable values. Capture any value +you intend to overwrite later, so the continuation can tell whether it changed: + +```csharp +var job = new Job +{ + Target = state, // carried, never dereferenced off-thread + Expected = account.Password, // captured so the continuation can detect a change + Input = DerivePhrase(...) // policy resolved here, on the loop +}; + +if (!Worker.TryEnqueue(job)) +{ + // Full. Reject -- do not fall back to running it inline, or a flood steers the work + // straight back onto the loop. +} +``` + +**Worker → loop (hand back).** Two sanctioned routes, and no others: + +```csharp +// 1. Marshal the apply step. Preferred when a specific result belongs to a specific caller. +Core.LoopContext.Post(() => Apply(job, result)); + +// 2. Publish an immutable snapshot behind a single volatile reference, read lock-free by the loop. +// Preferred for a shared lookup table rebuilt periodically. +Volatile.Write(ref _snapshot, newTable); +``` + +**The continuation must re-validate.** Time passed, and the loop kept running: + +```csharp +private static void Apply(Job job, Result result) +{ + // Gone? Never revive a dead NetState or a deleted entity. + if (job.Target?.Running != true) + { + return; + } + + // Changed? Do not overwrite a newer value with one derived from an older one. + if (!string.Equals(account.Password, job.Expected, StringComparison.Ordinal)) + { + return; + } + + account.Apply(result); +} +``` + +**Always post a result, including on failure.** A worker that throws and posts nothing leaves +whatever awaited it waiting forever. Catch, log, and post a failure verdict. + +**Never** call into game state from the worker, and never `await` on the loop in a way that lets a +continuation resume heavy work there -- `ConfigureAwait(false)` on every await inside off-loop work. + ## Memory Pooling ### STArrayPool From 6d846b11e5b77831b995d953f968663f58a0d64d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:24:59 -0700 Subject: [PATCH 35/64] perf: Sleep the event loop when idle. Fixes networking micro-stalls. Adds event loop instrumentation. (#2559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `RunEventLoop` span through its body regardless of whether there was anything to do — ~10% of a desktop core for an empty shard, and ~70% of a core on a 3 vCPU VPS. A process that never idles is exactly what burstable vCPU plans throttle, which is how this surfaced: lag spikes that went away when the operator bought more cores. The spin also denied the GC its natural pause points, so memory climbed until a world save forced a collection — alarming in task manager, harmless in practice, and a recurring source of "is my server leaking?" reports. ## Result Windows desktop, real world of **190,728 items / 33,158 mobiles**, no players, saves and prebake off, three consecutive runs: | | Legacy spin | Idle sleeping | |---|---|---| | **CPU** | 10.42 – 10.50% of one core | **0.78 – 1.00%** | | **Tick lag** (peak/15s) | 4–10 ms | 5–11 ms | **~10× less CPU with tick lag unchanged** — the CPU came free rather than being traded for latency. Slower hosts gain proportionally more. Spin mode (`server.eventLoopIdleWaitMs=0`) independently gained **7× the iterations per core** (1.19M → 8.3M cycles/sec) from the ring's AcceptEx rework. ## How The loop blocks in `NetState.WaitForCompletion` whenever every queue it drains is empty (all the drains are bounded, so leftovers keep it awake). Receive completions, new connections, and cross-thread `LoopContext.Post` (via the ring's sticky `Wake()`) are all in the wait set, so sleeping adds no latency to any of them. Only timer-driven logic sees wheel lag, bounded by the idle wait. **Health is measured at the only place sleeping can cause harm.** A sleep is bounded by the time to the next wheel turn, so a correctly honoured sleep can never miss a deadline — the only failure mode is the host returning the wait late. That overshoot is measured on every sleep (one extra timestamp read; production's entire accounting cost), and an escalating backoff suspends sleeping when it persists. By construction, server work — saves, heavy staff commands, deep timer callbacks — cannot trip it, so the warning means exactly one thing: *the host is not scheduling the process promptly*, with two known remedies (dedicated CPU, or `=0`). Hosts with no high-resolution wait mechanism at all are detected once at startup and spin instead. **CPS is removed.** `Core.CyclesPerSecond`/`AverageCPS` measured nothing actionable before and became actively misleading once the loop sleeps (the rate is set by the sleep, not by shard health). The admin gump's Performance page now shows the verdict instead: `Healthy` / `Sleep suspended (host)` / `Spinning (configured)`. ## Configuration | Setting | Default | Meaning | |---|---|---| | `server.eventLoopIdleWaitMs` | `2` | Longest idle block. Measured across 1/2/4/8 ms, 2 is where the trade stops being free. `0` = never sleep: ~98% of a core, zero scheduling overhead — for large shards on dedicated CPU. | | `server.lateWakeThreshold` | `1` | Idle waits the host may return a full tick late, per second, before sleeping backs off. Raise for jittery hosts; very high disables the backoff. | ## Diagnostics (compiled out by default) `dotnet build -p:EventLoopProfiling=true` compiles in `EventLoopProfiler` — every hook is `[Conditional("EVENT_LOOP_PROFILING")]`, so normal builds contain zero profiling IL. The profiling build decomposes each second of wall time into **work (per loop phase) / sleep / GC pause / stolen residual**, keeps ~15 minutes of history in a ring buffer, and the `[LoopStats` command prints the last minute and dumps the full history to CSV. `dev-docs/debugging-event-loop.md` is the diagnosis guide (for humans and AI): what production already tells you, when to flip the profiling build, the signature table for host-steal vs deep-processing vs GC vs wake bugs, why dotnet-trace comes last, and the GC/RAM "leak" misconception. ## Verification - 815 Server.Tests green; both build configurations compile. - Docker echo harness green on epoll and io_uring (ping-pong mode); kqueue verified manually on an M1 Max. - A/B measurements and per-change numbers: `measure/event-loop` branch. ## Notes The full measurement harness and vendored ring sources used to develop this live on the [`measure/event-loop`](https://github.com/modernuo/ModernUO/tree/measure/event-loop) branch, kept for future loop work. --- .gitignore | 2 + CLAUDE.md | 17 +- Directory.Build.props | 7 + .../Tests/Network/EventLoopIdleTests.cs | 69 +++++ .../Server/Diagnostics/EventLoopProfiler.cs | 233 +++++++++++++++++ Projects/Server/EventLoopTasks.cs | 45 +++- Projects/Server/Items/Item.cs | 6 + Projects/Server/Main.cs | 245 +++++++++++++++--- Projects/Server/Mobiles/Mobile.cs | 6 + .../Network/NetState/NetState.Network.cs | 18 ++ Projects/Server/Server.csproj | 2 +- Projects/Server/Timer/Timer.TimerWheel.cs | 7 + Projects/UOContent/Commands/LoopStats.cs | 117 +++++++++ Projects/UOContent/Gumps/AdminGump.cs | 8 +- README.md | 19 +- dev-docs/claude-skills/modernuo-code-audit.md | 21 +- dev-docs/debugging-event-loop.md | 115 ++++++++ dev-docs/server-requirements.md | 120 +++++++++ dev-docs/tick-counts.md | 55 ++++ 19 files changed, 1066 insertions(+), 46 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs create mode 100644 Projects/Server/Diagnostics/EventLoopProfiler.cs create mode 100644 Projects/UOContent/Commands/LoopStats.cs create mode 100644 dev-docs/debugging-event-loop.md create mode 100644 dev-docs/server-requirements.md create mode 100644 dev-docs/tick-counts.md diff --git a/.gitignore b/.gitignore index 2cc93a052..d5b26e268 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /Distribution/Configuration/blocklist.json /Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json +/Distribution/Configuration/firewall.json /Distribution/Configuration/ip-allowlist*.txt /Distribution/Configuration/ip-allowlist*.txt.tmp /Distribution/Configuration/ip-blocklist.txt @@ -25,6 +26,7 @@ /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json /Distribution/Configuration/tot.json +/Distribution/Data/Pathfinding /Distribution/Logs /Distribution/Archives /Distribution/Backups diff --git a/CLAUDE.md b/CLAUDE.md index b1a90f6ea..849ff3175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md` 18. **Interpolation anti-patterns on handler-aware APIs** — `Send*`/`Say`/`Emote`/`PublicOverhead*`/`IPropertyList.Add`/gump `AddLabel`/`AddHtml`/`Html.Center`/`SpanWriter.Write*` all have `ref RawInterpolatedStringHandler` overloads that allocate zero strings, but only when the call-site argument is a `$"..."` literal directly. Avoid: ternaries with interpolated branches (`Send(c ? $"a" : $"b")`), switch expressions with interpolated arms, pre-built `var s = $"..."` locals (single-use), `.ToString()` / `.String()` / `string.Format` inside holes, string concat (`{a + b}`), LINQ string ops in holes. Use `:L` format spec for lowercase (`{rank:L}` not `rank.ToString().ToLowerInvariant()`) → `dev-docs/string-handling.md` § Interpolation Anti-Patterns 19. **No `InvalidateProperties()` from inside `GetProperties`** — every property a `GetProperties` override reads must be a pure read. `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild), and `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. A getter that invalidates therefore throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from an unrelated-looking line, or silently corrupts the tooltip. The engine refuses and logs an error; `DEBUG` throws. Lazy recomputation in a getter is fine — the *notification* is not. Invalidate in the setter that changes the value, or defer with `Timer.DelayCall(InvalidateProperties)` → `dev-docs/property-lists.md` § Never Invalidate From Inside `GetProperties` +20. **Tick-count math must be wraparound-safe** — compare `Core.TickCount`/`GetTimestamp()` values only by subtraction (`a - b < 0`, never `a < b`), no zero/sign sentinels on tick fields, seed deadline fields from a real tick (never rely on the 0 default). Cloud hypervisors (GCP) pass through the host's never-resetting counter: ticks start enormous and can wrap negative. Linux affected in production; Windows not so far → `dev-docs/tick-counts.md` ## Dev-Docs Reference @@ -45,6 +46,9 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Commands & targeting | `dev-docs/commands-targeting.md` | | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | +| Server hardware requirements | `dev-docs/server-requirements.md` | +| Debugging event-loop performance (profiling build, decomposition, GC/RAM) | `dev-docs/debugging-event-loop.md` | +| Tick-count overflow rules (subtraction comparisons; GCP pass-through counters) | `dev-docs/tick-counts.md` | | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | | Platform prerequisites (ICU, tzdata, native libs per distro) | `dev-docs/platform-prerequisites.md` | | Configuration system | `dev-docs/configuration.md` | @@ -95,7 +99,18 @@ Then copy only the relevant skill files based on the task: | Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` | | Migrate multi-file system | `migrate-from-runuo/migrate-systems` | -To enable a skill: `cp dev-docs/claude-skills/.md .claude/skills/` +To enable a skill — Claude Code loads `.claude/skills//SKILL.md`; a bare `.md` dropped +directly into `.claude/skills/` is **not** picked up, and newly installed skills appear in the +*next* session: + +```sh +# Standard skills (modernuo-*) +mkdir -p .claude/skills/ && cp dev-docs/claude-skills/.md .claude/skills//SKILL.md + +# Migration skills — sources live in the migrate-from-runuo/ subfolder, but install under the +# bare skill name (the table's "migrate-from-runuo/" is the source path, not the name): +mkdir -p .claude/skills/ && cp dev-docs/claude-skills/migrate-from-runuo/.md .claude/skills//SKILL.md +``` Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices. diff --git a/Directory.Build.props b/Directory.Build.props index 59311d439..30bf85099 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -63,6 +63,13 @@ ..\..\Rules.ruleset latest + + + $(DefineConstants);EVENT_LOOP_PROFILING + diff --git a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs new file mode 100644 index 000000000..dfb8c7be7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs @@ -0,0 +1,69 @@ +using Xunit; + +namespace Server.Tests; + +/// +/// The event loop only sleeps when every queue it drains is empty. These drains are deliberately +/// bounded -- ExecuteTasks stops at its per-frame cap -- so leftover work is normal and must keep +/// the loop awake. Getting this wrong strands queued work for the length of a sleep. +/// +[Collection("Sequential Server Tests")] +public class EventLoopIdleTests +{ + [Fact] + public void FreshContextIsEmpty() + { + var context = new EventLoopContext(); + + Assert.True(context.IsEmpty); + } + + [Fact] + public void PostedWorkMakesContextNonEmpty() + { + var context = new EventLoopContext(); + + context.Post(() => { }); + + Assert.False(context.IsEmpty); + } + + [Fact] + public void PriorityWorkMakesContextNonEmpty() + { + var context = new EventLoopContext(); + + context.Post(() => { }, EventLoopContext.Priority.High); + + Assert.False(context.IsEmpty); + } + + [Fact] + public void ContextIsEmptyAgainOnceDrained() + { + var context = new EventLoopContext(); + context.Post(() => { }); + + context.ExecuteTasks(); + + Assert.True(context.IsEmpty); + } + + [Fact] + public void WorkBeyondThePerFrameCapKeepsContextNonEmpty() + { + // The cap is what makes IsEmpty necessary: a single ExecuteTasks pass cannot be assumed + // to have drained everything, so the loop must not treat "I just ran tasks" as "idle". + const int perFrameCap = 128; + var context = new EventLoopContext(perFrameCap); + + for (var i = 0; i < perFrameCap + 10; i++) + { + context.Post(() => { }); + } + + context.ExecuteTasks(); + + Assert.False(context.IsEmpty); + } +} diff --git a/Projects/Server/Diagnostics/EventLoopProfiler.cs b/Projects/Server/Diagnostics/EventLoopProfiler.cs new file mode 100644 index 000000000..a3fd157f5 --- /dev/null +++ b/Projects/Server/Diagnostics/EventLoopProfiler.cs @@ -0,0 +1,233 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: EventLoopProfiler.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.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Server; + +public enum LoopPhase +{ + MobileDeltas, + ItemDeltas, + TimerSlice, + NetworkSlice, + LoopTasks, +} + +/// +/// Event-loop time accounting, compiled out of normal builds. Build with +/// -p:EventLoopProfiling=true to enable; every hook is +/// [Conditional("EVENT_LOOP_PROFILING")], so without the flag the call sites do not exist +/// in the IL and this class is dormant. See dev-docs/debugging-event-loop.md for how to read it. +/// +/// +/// Each one-second sample decomposes wall time into work (per ), sleep, +/// GC pause, and a stolen residual (wall - work - sleep): time the host ran something else. +/// Samples land in a ring buffer (~15 minutes) so a lag episode can be compared against the good +/// minutes on the same box, build, and world — the baseline RunUO's profiler never had. +/// +public static class EventLoopProfiler +{ + public const int PhaseCount = 5; + private const int RingSize = 900; + private const long SampleIntervalMs = 1000; + + public struct Sample + { + public long WallStart; // Core.TickCount at sample start + public long WallMs; // sample length + public long Iterations; + public long Sleeps; + public double SleepMs; // total time blocked in WaitForCompletion + public double SleepOvershootMaxMs; // worst (elapsed - requested) this sample + public long LateWakes; // overshoot >= Timer.TickRate + public long WheelLagMaxMs; // worst wheel lateness observed at Slice entry + public long WakesIssued; + public long WakesElided; + public double GcPauseMs; // GC.GetTotalPauseDuration delta + public int Gen0; + public int Gen1; + public int Gen2; + public PhaseTimes Phases; + + // Work the phases did not account for and the loop did not spend sleeping: host + // scheduling steals, and anything between the bracketed phases. GC pauses inside a + // phase or sleep inflate those measurements instead, so GcPauseMs overlaps rather + // than subtracts. + public double StolenMs + { + get + { + var known = SleepMs + Phases.Total; + return WallMs > known ? WallMs - known : 0; + } + } + } + + [InlineArray(PhaseCount)] + public struct PhaseTimes + { + private double _element0; + + public double Total + { + get + { + double total = 0; + for (var i = 0; i < PhaseCount; i++) + { + total += this[i]; + } + + return total; + } + } + } + + private static readonly double _msPerTick = 1000.0 / Stopwatch.Frequency; + + private static Sample[] _ring; + private static int _ringCount; + private static int _ringHead; + + private static Sample _current; + private static long _phaseStartTimestamp; + private static long _sampleStartedAt; + private static TimeSpan _lastGcPause; + private static int _lastGen0; + private static int _lastGen1; + private static int _lastGen2; + + /// Number of samples recorded so far (capped at the ring size). + public static int SampleCount => _ringCount; + + /// The sample currently being accumulated (not yet in the ring). + public static Sample Current => _current; + + /// + /// Copies the newest completed samples, oldest first. + /// + public static Sample[] History(int count = RingSize) + { + count = Math.Min(count, _ringCount); + var result = new Sample[count]; + for (var i = 0; i < count; i++) + { + result[i] = _ring[(_ringHead - count + i + RingSize) % RingSize]; + } + + return result; + } + + [Conditional("EVENT_LOOP_PROFILING")] + public static void IterationStart(long tickCount) + { + if (_ring == null) + { + _ring = new Sample[RingSize]; + _sampleStartedAt = tickCount; + _current.WallStart = tickCount; + _lastGcPause = GC.GetTotalPauseDuration(); + _lastGen0 = GC.CollectionCount(0); + _lastGen1 = GC.CollectionCount(1); + _lastGen2 = GC.CollectionCount(2); + } + + _current.Iterations++; + + if (tickCount - _sampleStartedAt < SampleIntervalMs) + { + return; + } + + _current.WallMs = tickCount - _sampleStartedAt; + + var pause = GC.GetTotalPauseDuration(); + _current.GcPauseMs = (pause - _lastGcPause).TotalMilliseconds; + _lastGcPause = pause; + + var gen0 = GC.CollectionCount(0); + var gen1 = GC.CollectionCount(1); + var gen2 = GC.CollectionCount(2); + _current.Gen0 = gen0 - _lastGen0; + _current.Gen1 = gen1 - _lastGen1; + _current.Gen2 = gen2 - _lastGen2; + _lastGen0 = gen0; + _lastGen1 = gen1; + _lastGen2 = gen2; + + _ring[_ringHead] = _current; + _ringHead = (_ringHead + 1) % RingSize; + if (_ringCount < RingSize) + { + _ringCount++; + } + + _sampleStartedAt = tickCount; + _current = default; + _current.WallStart = tickCount; + } + + [Conditional("EVENT_LOOP_PROFILING")] + public static void PhaseStart(LoopPhase phase) => _phaseStartTimestamp = Stopwatch.GetTimestamp(); + + [Conditional("EVENT_LOOP_PROFILING")] + public static void PhaseEnd(LoopPhase phase) => + _current.Phases[(int)phase] += (Stopwatch.GetTimestamp() - _phaseStartTimestamp) * _msPerTick; + + [Conditional("EVENT_LOOP_PROFILING")] + public static void SleepEnd(int requestedMs, long elapsedMs) + { + _current.Sleeps++; + _current.SleepMs += elapsedMs; + + var overshoot = elapsedMs - requestedMs; + if (overshoot > _current.SleepOvershootMaxMs) + { + _current.SleepOvershootMaxMs = overshoot; + } + + if (overshoot >= Timer.TickRate) + { + _current.LateWakes++; + } + } + + [Conditional("EVENT_LOOP_PROFILING")] + public static void WheelSlice(long deltaSinceTurn) + { + var lag = deltaSinceTurn - Timer.TickRate; + if (lag > _current.WheelLagMaxMs) + { + _current.WheelLagMaxMs = lag; + } + } + + // Cross-thread; approximate counts are fine for diagnosis, so no interlocked. + [Conditional("EVENT_LOOP_PROFILING")] + public static void WakeSignal(bool elided) + { + if (elided) + { + _current.WakesElided++; + } + else + { + _current.WakesIssued++; + } + } +} diff --git a/Projects/Server/EventLoopTasks.cs b/Projects/Server/EventLoopTasks.cs index 88ae35b49..c3ff2c10c 100644 --- a/Projects/Server/EventLoopTasks.cs +++ b/Projects/Server/EventLoopTasks.cs @@ -42,10 +42,47 @@ public sealed class EventLoopContext : SynchronizationContext public override SynchronizationContext CreateCopy() => new EventLoopContext(); - public void Post(Action d, Priority priority = Priority.Normal) => - (priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); + /// + /// True when no callbacks are waiting to run. + /// + /// + /// drains at most _maxPerFrame callbacks, so work can + /// legitimately be left over. The event loop checks this before sleeping so a backlog keeps + /// it running instead. + /// + public bool IsEmpty => _queue.IsEmpty && _priorityQueue.IsEmpty; - public override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state)); + public void Post(Action d, Priority priority = Priority.Normal) + { + (priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); + WakeEventLoop(); + } + + public override void Post(SendOrPostCallback d, object state) + { + _queue.Enqueue(() => d(state)); + WakeEventLoop(); + } + + /// + /// Nudges the game loop in case it is asleep: the loop blocks on network I/O, which a queue + /// push alone does not signal. + /// + private void WakeEventLoop() + { + // A post from the loop thread cannot need a wake -- the loop is executing this very call + // -- and the signal is a syscall on every backend. + if (Thread.CurrentThread == _mainThread) + { + EventLoopProfiler.WakeSignal(elided: true); + return; + } + + EventLoopProfiler.WakeSignal(elided: false); + + // Safe before networking is configured and after teardown; NetState.Wake does nothing. + Network.NetState.Wake(); + } public override void Send(SendOrPostCallback d, object state) { @@ -63,6 +100,8 @@ public sealed class EventLoopContext : SynchronizationContext evt.Set(); }); + WakeEventLoop(); + evt.WaitOne(); } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 186437400..64cef9526 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -3325,6 +3325,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert m_DeltaFlags &= ~flags; } + /// + /// True when deltas remain queued after a pass, which is + /// bounded by the count it saw on entry. The event loop consults this before sleeping. + /// + public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0; + public static void ProcessDeltaQueue() { var limit = m_DeltaQueue.Count; diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 2be9d7d04..eb95e6d4e 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -39,10 +39,142 @@ public static class Core { private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); - private static bool _performProcessKill; + // Written from other threads (Kill, RequestSnapshot) and read by the event loop. Volatile + // because the loop now genuinely blocks between reads rather than spinning past them. + private static volatile bool _performProcessKill; private static bool _restartOnKill; - private static bool _performSnapshot; + private static volatile bool _performSnapshot; private static string _snapshotPath; + + // A backstop, not a latency control: the wheel's tick rate bounds the sleep, so this only + // limits the damage if a wake signal is ever missed. Measured across 1/2/4/8ms, 2 is optimal. + private static int _eventLoopIdleWaitMs = 2; + + /// + /// Longest the loop will block while idle, in milliseconds. 0 disables idle sleeping, + /// leaving the loop to spin; the adaptive backoff does the same thing temporarily when the + /// host keeps returning waits late. + /// + public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs; + + /// + /// Whether idle sleeping is currently suspended because the host returned waits late. + /// + /// + /// Compared by subtraction, never directly: tick counts can start enormous and wrap. + /// See dev-docs/tick-counts.md. + /// + public static bool IdleSleepSuspended => _tickCount - _idleSleepSuspendedUntil < 0; + + private const long HealthSampleIntervalMs = 1000; + + // Backoff escalates by doubling: a fixed suspension oscillates forever on a persistently bad + // host, while doubling converges on "stop sleeping" within minutes yet still recovers from a + // transient problem. + private const long BackoffBaseMs = 5000; + private const long BackoffMaxMs = 120_000; + private const int BackoffMaxShift = 5; + + // Clean streak that clears the escalation. + private const long BackoffResetAfterCleanMs = 60_000; + + // A sleep is bounded by the time to the next wheel turn, so a correctly honoured sleep can + // never miss a deadline; the only way sleeping harms the wheel is the wait returning late + // (the host descheduled the process). That overshoot is measured per sleep, which is why + // server work -- saves, heavy commands, deep timer callbacks -- cannot trip this backoff. + // Loop-thread only, so plain increments are safe. + private static int _lateWakes; + + private static long _nextHealthSample; + private static long _idleSleepSuspendedUntil; + private static int _lateWakeThreshold = 1; + private static long _idleSleepBackoffs; + private static int _consecutiveBadSamples; + private static int _consecutiveBackoffs; + private static long _currentBackoffMs = BackoffBaseMs; + private static long _lastBackoffAt; + private static bool _loggedBackoffCeiling; + + /// + /// Once a second, suspends idle sleeping (with escalating duration) if the host keeps + /// returning idle waits a full tick or more late. + /// + private static void CheckSchedulerHealth() + { + if (_tickCount - _nextHealthSample < 0) + { + return; + } + + _nextHealthSample = _tickCount + HealthSampleIntervalMs; + + var late = _lateWakes; + _lateWakes = 0; + + if (late <= _lateWakeThreshold) + { + _consecutiveBadSamples = 0; + return; + } + + // Require the condition to persist: any host can drop one sample to unrelated load, and a + // host that is genuinely oversubscribed stays that way, so it trips on the second sample. + if (++_consecutiveBadSamples < 2) + { + return; + } + + if (_eventLoopIdleWaitMs <= 0) + { + return; + } + + // Already suspended: extend rather than counting a fresh backoff episode. + if (_tickCount - _idleSleepSuspendedUntil < 0) + { + _idleSleepSuspendedUntil = _tickCount + _currentBackoffMs; + return; + } + + // A long clean streak resets the escalation. Gated on the count rather than a + // "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. + if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) + { + _consecutiveBackoffs = 0; + } + + _currentBackoffMs = Math.Min(BackoffBaseMs << Math.Min(_consecutiveBackoffs, BackoffMaxShift), BackoffMaxMs); + _consecutiveBackoffs++; + _lastBackoffAt = _tickCount; + _idleSleepSuspendedUntil = _tickCount + _currentBackoffMs; + _idleSleepBackoffs++; + + if (_currentBackoffMs >= BackoffMaxMs) + { + // Escalation has run out of room; say so once in terms the operator can act on. + if (!_loggedBackoffCeiling) + { + _loggedBackoffCeiling = true; + logger.Error( + "This host keeps returning idle waits late and sleeping has backed off {Count} times. " + + "The process is not being scheduled promptly, which is typical of shared or burstable vCPUs. " + + "Set server.eventLoopIdleWaitMs to 0 to disable sleeping permanently and trade a full core for latency.", + _idleSleepBackoffs + ); + } + + return; + } + + logger.Warning( + "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} time(s) in the last " + + "second; idle sleeping suspended for {Duration}ms", + _eventLoopIdleWaitMs, + Timer.TickRate, + late, + _currentBackoffMs + ); + } private static bool _crashed; private static string _baseDirectory; @@ -111,14 +243,6 @@ public static class Core public static long Uptime => TickCount - _firstTick; - private static double _currentCPS; - private static double _averageCPS; - private static bool _cpsInitialized; - - public static double CyclesPerSecond => _currentCPS; - - public static double AverageCPS => _averageCPS; - public static string BaseDirectory { get @@ -235,6 +359,10 @@ public static class Core { _restartOnKill = restart; _performProcessKill = true; + + // Callers are usually off-loop (console input, signal handlers). Without this the loop + // would not notice the request until it woke for some other reason. + NetState.Wake(); } public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) @@ -424,6 +552,13 @@ public static class Core ServerConfiguration.Load(); + // 0 disables idle sleeping entirely (full-core spin, zero scheduling overhead). + _eventLoopIdleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); + + // 16ms-budget misses per second before idle sleeping backs off. Raise to tolerate a + // jittery host; set very high to disable the backoff. + _lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); + var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); // Load UOContent.dll @@ -453,6 +588,12 @@ public static class Core _now = DateTime.UtcNow; _firstTick = _tickCount = GetTimestamp(); + // Seed schedule state from the first real tick: tick counts are not guaranteed to start + // anywhere near zero (hypervisor pass-through counters), so zero-initialized deadlines + // would compare wrong. See dev-docs/tick-counts.md. + _nextHealthSample = _tickCount + HealthSampleIntervalMs; + _idleSleepSuspendedUntil = _tickCount; + Timer.Init(_tickCount); AssemblyHandler.Invoke("Configure"); @@ -469,34 +610,63 @@ public static class Core NetState.Start(); PingServer.Start(); EventSink.InvokeServerStarted(); + + // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop would + // quietly run a tick behind; spinning is the lesser evil and must not be silent. Only + // fires when both the ring's high-res timer and its timeBeginPeriod fallback failed. + if (_eventLoopIdleWaitMs > 0 && NetState.Ring?.SupportsHighResolutionWait == false) + { + logger.Error( + "This host cannot honor short waits (no high-resolution timer, and raising the system timer " + + "resolution failed). Idle sleeping is disabled. The loop will spin instead, using a full core." + ); + + _eventLoopIdleWaitMs = 0; + } + RunEventLoop(); } + /// + /// True when every queue the loop drains is empty, so sleeping cannot strand pending work. + /// The drains are bounded (ProcessDeltaQueue stops at the count seen on entry, ExecuteTasks + /// at its per-frame cap), so leftovers are normal and must keep the loop awake. + /// + private static bool IsIdle() => + !Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle; + public static void RunEventLoop() { try { - var lastRaw = Stopwatch.GetTimestamp(); - const int interval = 100; - double frequency = Stopwatch.Frequency * interval; - const double alpha = 2.0 / 129; // EMA smoothing (≈128-sample window) - - var sample = 0; - while (!Closing) { _tickCount = GetTimestamp(); _now = DateTime.UtcNow; + EventLoopProfiler.IterationStart(_tickCount); + + EventLoopProfiler.PhaseStart(LoopPhase.MobileDeltas); Mobile.ProcessDeltaQueue(); + EventLoopProfiler.PhaseEnd(LoopPhase.MobileDeltas); + + EventLoopProfiler.PhaseStart(LoopPhase.ItemDeltas); Item.ProcessDeltaQueue(); + EventLoopProfiler.PhaseEnd(LoopPhase.ItemDeltas); + + EventLoopProfiler.PhaseStart(LoopPhase.TimerSlice); Timer.Slice(_tickCount); + EventLoopProfiler.PhaseEnd(LoopPhase.TimerSlice); // Handle networking + EventLoopProfiler.PhaseStart(LoopPhase.NetworkSlice); NetState.Slice(); + EventLoopProfiler.PhaseEnd(LoopPhase.NetworkSlice); // Execute captured post-await methods (like Timer.Pause) + EventLoopProfiler.PhaseStart(LoopPhase.LoopTasks); LoopContext.ExecuteTasks(); + EventLoopProfiler.PhaseEnd(LoopPhase.LoopTasks); Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it. @@ -513,29 +683,28 @@ public static class Core break; } - if (sample++ == interval) + CheckSchedulerHealth(); + + if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle()) { - sample = 0; - var nowRaw = Stopwatch.GetTimestamp(); - - _currentCPS = frequency / (nowRaw - lastRaw); - - if (!_cpsInitialized) + // Re-read the clock: the loop body consumed real time, and a stale timestamp + // would overstate the time to the next tick and sleep straight past it. + var start = GetTimestamp(); + var due = Timer.MillisecondsUntilNextTick(start); + if (due > 0) { - _averageCPS = _currentCPS; - _cpsInitialized = true; - } - else - { - _averageCPS += alpha * (_currentCPS - _averageCPS); - } + var requested = (int)Math.Min(due, _eventLoopIdleWaitMs); + NetState.WaitForCompletion(requested); - lastRaw = nowRaw; + var elapsed = GetTimestamp() - start; + EventLoopProfiler.SleepEnd(requested, elapsed); - var sleepMs = (int)Timer.MillisecondsUntilNextTick(_tickCount); - if (sleepMs >= 2) - { - NetState.WaitForCompletion(sleepMs - 1); + // A sleep is bounded by the next wheel turn, so only a wait the host + // returned late can cost the wheel a deadline. + if (elapsed - requested >= Timer.TickRate) + { + _lateWakes++; + } } } } @@ -553,6 +722,10 @@ public static class Core { _snapshotPath = snapshotPath; _performSnapshot = true; + + // Save requests arrive off-loop. Wake so the snapshot starts now rather than after the + // loop happens to surface for another reason. + NetState.Wake(); } public static void VerifySerialization() diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index f22c9a939..e47f977a7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -7834,6 +7834,12 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } + /// + /// True when deltas remain queued after a pass, which is + /// bounded by the count it saw on entry. The event loop consults this before sleeping. + /// + public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0; + public static void ProcessDeltaQueue() { var limit = m_DeltaQueue.Count; diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 7a17d8cd0..9cb7b85fb 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -71,6 +71,24 @@ public partial class NetState _socketManager?.WaitForCompletion(timeoutMs); } + /// + /// Wakes the game loop if it is blocked in . Safe from any + /// thread; a no-op before networking is configured or after teardown. The signal is sticky, + /// so a wake racing the loop's decision to sleep is not lost. + /// + public static void Wake() + { + _socketManager?.Ring?.Wake(); + } + + /// + /// True when no queued network work remains for the loop to drain. defers + /// work in several places, so an empty completion queue alone is not enough. + /// + internal static bool IsIdle => + _throttled.Count == 0 && _throttledPending.Count == 0 && + _flushPending.Count == 0 && _disposed.Count == 0; + /// /// Gets the listening addresses that the server is bound to. /// diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 8ad6a610b..49d8d496a 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,7 +34,7 @@ - + diff --git a/Projects/Server/Timer/Timer.TimerWheel.cs b/Projects/Server/Timer/Timer.TimerWheel.cs index 8c9dc47fd..2ab3ce547 100644 --- a/Projects/Server/Timer/Timer.TimerWheel.cs +++ b/Projects/Server/Timer/Timer.TimerWheel.cs @@ -51,8 +51,15 @@ public partial class Timer } } + /// + /// Milliseconds of simulated time one wheel turn advances. + /// + public static int TickRate => _tickRate; + public static void Slice(long tickCount) { + EventLoopProfiler.WheelSlice(tickCount - _lastTickTurned); + var deltaSinceTurn = tickCount - _lastTickTurned; while (deltaSinceTurn >= _tickRate) { diff --git a/Projects/UOContent/Commands/LoopStats.cs b/Projects/UOContent/Commands/LoopStats.cs new file mode 100644 index 000000000..012de68d8 --- /dev/null +++ b/Projects/UOContent/Commands/LoopStats.cs @@ -0,0 +1,117 @@ +#if EVENT_LOOP_PROFILING +using System; +using System.Globalization; +using System.IO; +using Server.Logging; + +namespace Server.Commands; + +/// +/// Reports the event-loop time decomposition recorded by . +/// Only compiled when the server is built with -p:EventLoopProfiling=true. +/// See dev-docs/debugging-event-loop.md for how to read the output. +/// +public static class LoopStats +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoopStats)); + + public static void Configure() + { + CommandSystem.Register("LoopStats", AccessLevel.Administrator, LoopStats_OnCommand); + } + + [Usage("LoopStats")] + [Description("Summarizes the last minute of event-loop time accounting and writes the full history to a CSV.")] + private static void LoopStats_OnCommand(CommandEventArgs e) + { + var history = EventLoopProfiler.History(); + if (history.Length == 0) + { + e.Mobile.SendMessage("No samples recorded yet."); + return; + } + + var window = Math.Min(60, history.Length); + + double wall = 0, sleep = 0, gc = 0, stolen = 0, stolenMax = 0; + long iterations = 0, sleeps = 0, lateWakes = 0, wheelLagMax = 0; + Span phases = stackalloc double[EventLoopProfiler.PhaseCount]; + Span phaseMax = stackalloc double[EventLoopProfiler.PhaseCount]; + + for (var i = history.Length - window; i < history.Length; i++) + { + ref var s = ref history[i]; + wall += s.WallMs; + sleep += s.SleepMs; + gc += s.GcPauseMs; + stolen += s.StolenMs; + iterations += s.Iterations; + sleeps += s.Sleeps; + lateWakes += s.LateWakes; + + if (s.StolenMs > stolenMax) + { + stolenMax = s.StolenMs; + } + + if (s.WheelLagMaxMs > wheelLagMax) + { + wheelLagMax = s.WheelLagMaxMs; + } + + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + phases[p] += s.Phases[p]; + if (s.Phases[p] > phaseMax[p]) + { + phaseMax[p] = s.Phases[p]; + } + } + } + + e.Mobile.SendMessage($"Loop, last {window}s of wall time {wall:F0}ms:"); + e.Mobile.SendMessage($" sleep {100 * sleep / wall:F1}%, gc {100 * gc / wall:F1}%, stolen {100 * stolen / wall:F1}% (worst {stolenMax:F0}ms/s)"); + + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + e.Mobile.SendMessage($" {(LoopPhase)p}: {100 * phases[p] / wall:F1}% (worst {phaseMax[p]:F0}ms/s)"); + } + + e.Mobile.SendMessage($" {iterations} iterations, {sleeps} sleeps, {lateWakes} late wakes, worst wheel lag {wheelLagMax}ms"); + + var path = Path.Combine(Core.BaseDirectory, $"loopstats-{Core.Now:yyyyMMdd-HHmmss}.csv"); + WriteCsv(path, history); + e.Mobile.SendMessage($"Full history ({history.Length} samples) written to {path}"); + logger.Information("Loop stats dumped to {Path}", path); + } + + private static void WriteCsv(string path, EventLoopProfiler.Sample[] history) + { + using var writer = new StreamWriter(path); + writer.Write("wallStart,wallMs,iterations,sleeps,sleepMs,sleepOvershootMaxMs,lateWakes,wheelLagMaxMs,wakesIssued,wakesElided,gcPauseMs,gen0,gen1,gen2,stolenMs"); + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + writer.Write(','); + writer.Write((LoopPhase)p); + } + + writer.WriteLine(); + + for (var i = 0; i < history.Length; i++) + { + ref var s = ref history[i]; + writer.Write(string.Create( + CultureInfo.InvariantCulture, + $"{s.WallStart},{s.WallMs},{s.Iterations},{s.Sleeps},{s.SleepMs:F2},{s.SleepOvershootMaxMs:F2},{s.LateWakes},{s.WheelLagMaxMs},{s.WakesIssued},{s.WakesElided},{s.GcPauseMs:F2},{s.Gen0},{s.Gen1},{s.Gen2},{s.StolenMs:F2}" + )); + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + writer.Write(','); + writer.Write(string.Create(CultureInfo.InvariantCulture, $"{s.Phases[p]:F2}")); + } + + writer.WriteLine(); + } + } +} +#endif diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 3a48bc5aa..130d031fc 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -227,9 +227,11 @@ namespace Server.Gumps } case AdminGumpPage.Information_Perf: { - AddLabel(20, 130, LabelHue, "Cycles Per Second:"); - AddLabel(40, 150, LabelHue, $"Current: {Core.CyclesPerSecond:N2}"); - AddLabel(40, 170, LabelHue, $"Average: {Core.AverageCPS:N2}"); + var loopStatus = Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" : + Core.IdleSleepSuspended ? "Sleep suspended - host returning waits late" : "Healthy"; + + AddLabel(20, 130, LabelHue, "Event Loop:"); + AddLabel(40, 150, LabelHue, loopStatus); using var sb = ValueStringBuilder.Create(); diff --git a/README.md b/README.md index 2321fe8e3..b8d49f544 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc ## Requirements #### Supported Operating Systems -[![Windows 10/11/2012/2016/2019/2022/2025](https://img.shields.io/badge/-server%202025-3c78d5?labelColor=222222&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0Ij48dGl0bGU+V2luZG93czwvdGl0bGU+PHBhdGggZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiIGZpbGw9IiMzYzc4ZDUiLz48L3N2Zz4=)](https://www.microsoft.com/en-US/evalcenter/evaluate-windows-server-2022) +[![Windows 10/11/2012 R2/2016/2019/2022/2025](https://img.shields.io/badge/-server%202025-3c78d5?labelColor=222222&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0Ij48dGl0bGU+V2luZG93czwvdGl0bGU+PHBhdGggZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiIGZpbGw9IiMzYzc4ZDUiLz48L3N2Zz4=)](https://www.microsoft.com/en-US/evalcenter/evaluate-windows-server-2022) ![MacOS 14+](https://img.shields.io/badge/-sonoma-222222?logo=apple&logoColor=white&labelColor=222222) [![Debian 12+](https://img.shields.io/badge/-trixie-A81D33?logo=debian&logoColor=A81D33&labelColor=222222)](https://www.debian.org/distrib/) [![Ubuntu 22+ LTS](https://img.shields.io/badge/-26LTS-E95420?logo=ubuntu&logoColor=E95420&labelColor=222222)](https://ubuntu.com/download/server) @@ -37,6 +37,23 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc ##### Windows [![VC++ Redistributable v14](https://img.shields.io/badge/-Redist%20v14-00599C?logo=cplusplus&logoColor=white&labelColor=222222)](https://aka.ms/vc14/vc_redist.x64.exe) +#### Hardware + +| Use | vCPU | RAM | Storage | +|---|---|---|---| +| Development / test | 2 **dedicated** | 2 GB | SSD | +| Small live shard (< 50 concurrent) | 4 dedicated | 4 GB | NVMe | +| Medium (50–200) | 4–8 | 8 GB | NVMe | +| Large (200+) | 8+, high clock | 16 GB+ | NVMe | + +Game logic is single-threaded, so **single-core clock speed matters more than core count**, and +**dedicated vCPU matters more than either** — burstable or shared plans throttle once credits run +out, which is the most common cause of unexplained lag spikes. Save size drives RAM more than +player count does. + +See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for the reasoning and tuning +options. + #### Development [![git](https://img.shields.io/badge/-git-F05032?logo=git&logoColor=F05032&labelColor=222222)](https://git-scm.com/downloads) [![.NET](https://img.shields.io/badge/-%2010.0.100%20SDK-5C2D91?logo=.NET&logoColor=white&labelColor=222222)](https://dotnet.microsoft.com/download/dotnet/10.0) diff --git a/dev-docs/claude-skills/modernuo-code-audit.md b/dev-docs/claude-skills/modernuo-code-audit.md index 5d5af15bb..d805b0578 100644 --- a/dev-docs/claude-skills/modernuo-code-audit.md +++ b/dev-docs/claude-skills/modernuo-code-audit.md @@ -200,8 +200,27 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold" **See**: `dev-docs/property-lists.md` § "Never Invalidate From Inside `GetProperties`". +### 20. Tick-Count Math Must Be Wraparound-Safe +**Check**: Every comparison between `Core.TickCount` / `Core.GetTimestamp()` values (or fields +derived from them — names like `*Until`, `*At`, `*Next*`, `deadline`) must be in subtraction form. +Flag direct comparisons, zero/sign sentinels, and deadline fields left at their zero default. +**Bad**: `if (Core.TickCount < _deadline)`; `if (_lastEventAt > 0)` as "has happened"; +`private static long _deadline;` compared before being seeded from a real tick. +**Good**: `if (Core.TickCount - _deadline < 0)`; a separate `bool` for "has happened"; seeding +deadline fields from the first observed timestamp. +**Why**: On some hypervisors — Google Cloud specifically — the VM receives a pass-through of the +host's never-resetting counter. Tick counts are NOT zero at process start, NOT zero at OS boot, +can be enormous from the first read, and can wrap negative. Direct comparisons and sign sentinels +then fail only on those hosts, after long host uptimes — the least reproducible bug class there +is. Windows has not shown this in testing; Linux has, in production. Subtraction of two ticks +wraps correctly in two's complement. +**Note**: `DateTime`/`DateTimeOffset` comparisons are unaffected; this applies only to the +monotonic tick domain. + +**See**: `dev-docs/tick-counts.md` for the full rules and review checklist. + ## Severity Levels -- **ERROR**: Rules 3, 9, 10, 13, 19 (will cause bugs, build failures, or client-side leaks) +- **ERROR**: Rules 3, 9, 10, 13, 19, 20 (will cause bugs, build failures, or client-side leaks) - **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14, 15, 17 (performance/convention issues) - **INFO**: Rules 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation), 16 (switch patterns — suggest but don't flag) - **ASK**: Rule 11 (need user input) diff --git a/dev-docs/debugging-event-loop.md b/dev-docs/debugging-event-loop.md new file mode 100644 index 000000000..5c5ea3b63 --- /dev/null +++ b/dev-docs/debugging-event-loop.md @@ -0,0 +1,115 @@ +# Debugging Event Loop Performance + +How to diagnose "the server feels slow" — written for both humans and AI assistants. Follow the +funnel in order; most incidents resolve before the last step. Do not start with dotnet-trace. + +## The model + +Every second of the main thread's wall time goes to exactly one of four places: + +1. **Work** — the loop's phases: mobile deltas, item deltas, timer callbacks (`Timer.Slice`), + network processing (`NetState.Slice`), posted tasks (`LoopContext`). +2. **Sleep** — idle blocking in `NetState.WaitForCompletion`, bounded by the next timer tick and + `server.eventLoopIdleWaitMs`. +3. **GC pauses** — land inside whichever phase (or sleep) was running. +4. **Stolen** — the host ran something else: hypervisor scheduling, noisy neighbors, CPU credit + throttling. + +A sleep is bounded by the time to the next wheel turn, so **a correctly honoured sleep can never +cost a deadline**. The only way sleeping harms the game is the wait *returning late* — that is +stolen time, and the server measures it directly on every sleep. + +## Step 0 — Read what production already tells you + +No build changes needed. Three signals exist, all actionable: + +| Signal | Meaning | Action | +|---|---|---| +| Startup error: *host cannot honour short waits* | No high-resolution timer and `timeBeginPeriod` failed. Very old or unusual Windows. | Nothing is wrong with the server; it spins and uses a full core. Upgrade the OS or accept the core. | +| Warning: *host returned a Nms idle wait late* + sleeping suspended | The OS did not reschedule the process promptly after a 1–2ms wait. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | +| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` | Same as above. | + +If none of these fired and the shard still feels laggy, the cause is work, GC, or something a +boot-time signal cannot see. Continue. + +## Step 1 — Flip the profiling build + +``` +dotnet build -p:EventLoopProfiling=true +``` + +This compiles in `EventLoopProfiler` (Server) and the `[LoopStats` command (UOContent). Without +the flag every hook call site is removed by the compiler (`[Conditional]`), so there is nothing to +"turn off" in normal builds and no cost to leave the hooks in the code. The profiling build's own +overhead is a handful of timestamp reads per iteration — small enough to run for days while +hunting an intermittent problem. + +**Capture a baseline first.** Run `[LoopStats` while the shard feels *fine* and keep the CSV. The +profiler also keeps ~15 minutes of history in memory, so if the problem is episodic you can wait +for an episode and the good minutes on either side are already recorded. Numbers without a +baseline are how RunUO's profiler became useless — always compare bad minutes to good minutes on +the same box, build, and world. + +## Step 2 — Read the decomposition + +`[LoopStats` prints the last minute and writes the full history CSV (one row per second). Match +the shape against these signatures: + +| Signature | Diagnosis | Next step | +|---|---|---| +| One phase consistently hot (e.g. `TimerSlice` 40%/s) | Deep processing in that subsystem | Step 3 — find the culprit in that phase | +| All phases near zero, `stolen` high, `lateWakes` > 0 | Host is stealing CPU | Host problem; see step 0 actions | +| `gcPauseMs` high, gen2 counts rising | GC pressure — something is allocating heavily | Step 3 on the allocating phase, or dotnet-counters for alloc rate | +| Iterations ≫ sleeps while shard is idle | The loop is not sleeping: a queue never drains or a wake storm | Check `IsIdle` inputs; a stuck signal in the ring is the historical example | +| Sleeps ≈ iterations, each sleep ~0ms | Spurious wake storm | Ring backend issue; count `wakesIssued` vs actual cross-thread posts | +| Everything normal, complaint persists | Not the event loop | Look at the network path, client, or DB/save timing | + +**Wheel lag vs player lag:** `wheelLagMaxMs` is how late timer callbacks fired. Receives are +handled the moment they arrive (they wake the loop), so player-felt lag with a clean wheel points +away from the loop entirely. + +## Step 3 — Find the culprit inside a hot phase + +Add a temporary culprit hook rather than reaching for a tracer. The pattern: same +`[Conditional("EVENT_LOOP_PROFILING")]` attribute, own file or the profiler file, record only the +worst offender per second (identity + duration), never a per-event log. Examples: + +- `TimerSlice` hot → time each timer callback, keep the max and its `timer.ToString()`. +- `NetworkSlice` hot → time packet handlers by packet id, keep the max. +- GC pressure → `dotnet-counters monitor --counters System.Runtime` for alloc rate first; it is + cheap and often names the culprit generation without a trace. + +Keep the hook after the hunt if it earns its cost in the profiling build; delete it otherwise. + +## Step 4 — dotnet-trace, last and targeted + +Only when a hot phase resists the culprit hook. Know the costs: EventPipe visibly slows the +process (worst exactly when things are already bad) and adds artifacts to the trace — on small +vCPU hosts the tracer's own threads appear as hotspots and Rider/PerfView hotspot views can +mislead. Mitigate by being narrow: + +- Trace the specific minutes the decomposition flagged, not "a while". +- `dotnet-trace collect --profile cpu-sampling --duration 00:00:30` is usually enough. +- Compare against a trace of a good minute (same rule as step 1: no baseline, no conclusions). + +## The RAM / GC misconception (read before declaring a leak) + +ModernUO allocates very little, and the GC collects opportunistically — mostly during idle sleeps +and world saves. Under a spinning loop (`eventLoopIdleWaitMs=0`, or the pre-2026 default) the GC +may find **no** natural pause point: memory climbs to a large fraction of physical RAM, a forced +collection eventually drops part of it, and fragmentation keeps the baseline permanently above +where it started. Task manager shows alarming numbers; the in-game numbers do not. **Performance +is unaffected — this is lazy collection working as designed, not a leak.** Idle sleeping largely +removes the effect because every sleep is a natural GC opportunity. Before investigating "a leak": +check `gen0/1/2` and `gcPauseMs` in the decomposition, and compare working set *after a world +save*, which forces the collection the spin loop never allowed. + +## Rules of thumb + +- Never trade always-on profiling for the numbers. Production carries one timestamp per sleep and + nothing else; everything heavier lives behind the build flag or on the `measure/event-loop` + branch (full harness, A/B scripts, vendored ring experiments). +- One decomposition chart beats a thousand log lines. Resist adding warnings the reader cannot + act on; the three production signals are deliberate. +- When filing or reporting: attach the baseline CSV and the episode CSV. Relative statements + ("TimerSlice went from 4% to 61% during the episode") are the useful form. diff --git a/dev-docs/server-requirements.md b/dev-docs/server-requirements.md new file mode 100644 index 000000000..daf71e03e --- /dev/null +++ b/dev-docs/server-requirements.md @@ -0,0 +1,120 @@ +# Server Requirements + +Hardware guidance for running a ModernUO shard. + +## Tiers + +| Use | vCPU | RAM | Storage | +|---|---|---|---| +| Development / test | 2 **dedicated** | 2 GB | SSD | +| Small live shard (< 50 concurrent) | 4 dedicated | 4 GB | NVMe | +| Medium (50–200) | 4–8 | 8 GB | NVMe | +| Large (200+) | 8+, high clock | 16 GB+ | NVMe | + +These are starting points. Save size drives RAM more than player count does, and single-thread +clock speed drives tick latency more than core count does. Both are explained below. + +## Dedicated vCPU, not burstable + +This matters more than any other line on this page. + +Budget VPS plans sold as "2 vCPU" are frequently shared or burstable: you get a CPU credit balance +or a cgroup quota, and once it is exhausted the hypervisor throttles you. Throttling shows up in +game as periodic freezes that correlate with nothing in your logs, and it is the single most common +cause of "ModernUO is laggy on my $3/month VPS". + +Symptoms worth checking before blaming the server: + +- Steal time above ~1% (`top`, the `%st` column on Linux) +- Lag that disappears when you move to a larger plan with the same core count +- Tick lag spikes with no matching CPU spike in the process itself + +## Cores + +Game logic is **single-threaded**. Every mobile, item, timer, and packet handler runs on one +thread, so a shard's headroom is bounded by how fast one core is. Two fast cores beat four slow +ones. + +Cores beyond the first are used by: + +- **World saves.** `world.useMultithreadedSaves` (default on) spins up `ProcessorCount - 1` + serialization workers plus one inline on the main thread. On a 2-core box that is one worker; on + a 2-core box with a large world, consider setting it to `false` so saves do not contend with the + loop. +- **The .NET runtime.** Tiered JIT compilation (heaviest in the first minutes after boot) and + background GC. +- **Everything else on the machine**, including your OS and, on Windows, antivirus. + +Since ModernUO 2026 the loop sleeps when idle, so an empty shard costs roughly 1% of a core rather +than spinning. That change disproportionately helps small hosts. + +## Memory + +Three things dominate, and only one of them scales with players. + +**World size.** A world of ~190,000 items and ~33,000 mobiles loads in about a second and is not +itself large. Items and mobiles are the cheap part. + +**Saves.** Each serialization worker pre-allocates a heap sized to its share of the last save, at +roughly 1.25× total save size, and those buffers are retained afterwards. A 400 MB save therefore +implies about 500 MB of resident serialization heap on top of the live world. **This is the reason +1 GB hosts are not viable for a real shard**, even though an empty one boots fine. + +**Map residency.** `TileMatrix` reads map blocks from disk on demand and caches them permanently — +there is no eviction. Memory climbs toward full-facet residency as players explore. Felucca's land +tiles alone are around 117 MB, and statics are larger. + +Optional systems can add substantially more. The pathfinding prebake +(`pathfinding.prebakeMaps`) peaks above 1 GB of heap while baking. Budget for it or leave it off on +small hosts. + +Network buffers are minor by comparison: 64 KB receive plus a configurable 256 KB send +(`network.sendBufferSize`) per connection, so 100 players is roughly 32 MB. + +ModernUO runs **Workstation GC**, which is the right default for small hosts. Do not switch to +Server GC on a 2-core box. + +## Storage + +Saves are write-heavy bursts. Cheap network-attached storage with throttled IOPS will stall the +save path, and `World.WaitForWriteCompletion` blocks the loop at shutdown. Use local NVMe or SSD. + +Budget disk for: the world save, plus archives and backups if `autoArchive` is enabled (retention +defaults keep 24 hourly, 30 daily, and 12 monthly copies), plus the pathfinding cache if enabled. + +## Operating systems + +See the README for the full supported list. Two things are worth calling out: + +- **Windows Server 2012 R2 and 2016 sleep via a raised timer resolution.** Sleeping for a couple + of milliseconds prefers a high-resolution waitable timer, which requires Windows 10 1803 / + Server 2019. On older versions the ring falls back to `timeBeginPeriod(1)`, which raises the + system timer resolution to 1 ms so the plain wait timeout is accurate enough. The trade-off is a + higher interrupt rate (system-wide on those versions) — an acceptable price on a dedicated game + server, and the reason the high-resolution timer is preferred where it exists. + + Only if *both* mechanisms fail does the server detect it at startup, log it, and spin instead — + the same behaviour as setting `server.eventLoopIdleWaitMs` to 0: a full core at idle, and zero + missed deadlines. A host that claims short waits but cannot deliver them is caught at runtime by + the adaptive backoff. +- **Linux kernel 6.1** or newer (Debian 12 and equivalents). io_uring is used where available, with + automatic epoll fallback. + +## Tuning for a small host + +| Setting | Default | Why change it | +|---|---|---| +| `server.eventLoopIdleWaitMs` | `2` | `0` never sleeps: ~98% of one core, but zero skipped timer slots and zero lag. The choice for a large shard on dedicated CPU that would rather spend a core than risk a late wake. Above `2` the wheel starts losing slots. | +| `server.lateWakeThreshold` | `1` | Idle waits the host may return a full tick late, per second, before idle sleeping backs off. Raise on a jittery host; set very high to disable the backoff. | +| `world.useMultithreadedSaves` | `true` | Set `false` on 2-core hosts so saves do not contend with the game loop. | +| `pathfinding.prebakeMaps` | varies | Leave off on memory-constrained hosts; it peaks above 1 GB while baking. | +| `network.sendBufferSize` | 256 KB | Lower it if you are memory-bound with many connections. | +| `autoArchive.*` retention | 24h/30d/12m | Reduce if disk is tight. | + +## Am I undersized? + +Watch the log. The server warns when the host returns idle waits late and suspends idle sleeping, +and says so at startup if the host cannot honour short waits at all. Those warnings mean the host +is not scheduling the process promptly — typical of burstable or shared vCPU plans — and no +server-side change fixes that. For anything deeper, see +[debugging-event-loop.md](debugging-event-loop.md). diff --git a/dev-docs/tick-counts.md b/dev-docs/tick-counts.md new file mode 100644 index 000000000..f53e223d4 --- /dev/null +++ b/dev-docs/tick-counts.md @@ -0,0 +1,55 @@ +# Tick Counts: Overflow and Huge Starting Values + +Rules for any code that compares `Core.TickCount` / `Core.GetTimestamp()` values. Getting this +wrong produces bugs that only appear on specific cloud hosts after long host uptimes — the worst +kind to reproduce. + +## Why this matters (the Linux/cloud problem) + +`Core.GetTimestamp()` is built on `Stopwatch.GetTimestamp()`, which on Linux reads the kernel's +monotonic clock — and on some hypervisors, notably **Google Cloud**, the VM receives a +**pass-through of the host's never-resetting counter**. The tick count is *not* zero when the +process starts and *not* zero when the operating system booted; it is however long the physical +host has been up, which can be months or years. We have been burned by this in production. + +Consequences: + +- Raw values are enormous from the first read. Arithmetic that would "never overflow in 292 + years" of process uptime can overflow immediately (`Core.GetTimestamp()`'s `UInt128` + conversion path exists precisely because `raw * 1000` does not fit in 64 bits for large raws). +- Wrapped values can be **negative**. Nothing may assume a tick count is positive. +- **Windows is not affected** in our testing so far, which is exactly why this class of bug + ships: it works on every dev machine and fails on a customer's GCP instance. + +## The rules + +1. **Compare by subtraction, never directly.** Subtraction of two ticks wraps correctly in two's + complement; direct comparison does not. + + ```csharp + // WRONG: fails when ticks wrap or start huge + if (Core.TickCount < deadline) + + // RIGHT: wraparound-safe + if (Core.TickCount - deadline < 0) + ``` + +2. **Durations are always subtractions of two readings** (`elapsed = end - start`). Never derive + a duration from a single absolute value. + +3. **No zero or sign sentinels.** `if (_lastEventAt > 0)` as "has this happened yet" breaks when + ticks are negative. Track "has happened" with a separate `bool` or an existing counter. + +4. **Seed deadline fields from a real tick, not from field initialization.** A `long _deadline;` + left at 0 compares wrong against a huge or negative tick. Initialize relative to the first + observed timestamp (see the schedule-state seeding in `Core.Setup`). + +5. **Store deadlines as `start + interval` only if every comparison follows rule 1.** The + addition may wrap; the subtraction comparison handles it. + +## Reviewing for it + +Grep the diff for `TickCount <`, `TickCount >`, `GetTimestamp() <`, and comparisons against any +field whose name suggests a deadline (`*Until`, `*At`, `*Next*`). Each hit must be in subtraction +form. `DateTime`/`DateTimeOffset` comparisons are unaffected; this applies only to the monotonic +tick domain. From 06289026440e913fd2c385d8dbb1958e0756c37b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:05:18 -0700 Subject: [PATCH 36/64] fix: harden idle-sleep scheduling against bad config and misattributed saves (#2567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to #2559, from a review of the ported idle-sleep/scheduler-health changes. ### Fixes - **`NetState.IsIdle` omitted `_pendingDisconnects`** — `Slice()` drains five queues; the property checked four. The other deferred work (`_connectingQueue`, alive checks, movement throttle) is time-gated and correctly excluded; the disconnect queue was the only ready-work omission. Impact was bounded (≤ one idle wait of delay), but the property's contract is "sleeping cannot strand pending work". - **Neither new setting was clamped** (`Main.cs`): - `server.lateWakeThreshold: -1` made `late <= threshold` false for every sample even at zero late wakes, so from the second sample on, sleeping was re-suspended every second, forever — a permanent full-core spin whose only trace was a nonsense warning ("… at least 8ms late 0 time(s)"). - `server.eventLoopIdleWaitMs: -1` disabled sleeping while the admin gump reported **Healthy** (it tested `== 0`). - Both now clamp to `>= 0` and log a warning naming the configured value. `-1` is a natural thing to reach for given the sibling key's doc says "set very high to disable". - **World snapshots were misattributed to `StolenMs`** — `World.Snapshot` ran outside all five profiler phases, so a 3-second save inside a sample read as ~75% stolen, and `debugging-event-loop.md` teaches stolen = "the host ran something else". The diagnostic pointed operators at buying dedicated CPU for their own largest loop-thread stall. Saves now land in a new `WorldSnapshot` phase; `[LoopStats` iterates `PhaseCount` generically, so the report and CSV pick it up with no changes. - **Admin gump conflated host-forced spin with configured spin** — when the startup probe finds no high-resolution wait support it zeroes the idle wait, after which the gump said "Spinning (configured)" and the operator's config said 2. New `Core.IdleSleepUnsupported` property; the gump now shows "Spinning - host cannot honor short waits" as a distinct fourth verdict. A genuinely configured 0 still reads "configured" (the probe only runs when the configured value was > 0). - **The backoff-ceiling `Error` logged once per process lifetime** — `_loggedBackoffCeiling` never reset, and at the ceiling the method returns before the `Warning`, so a host that recovered (>60s clean streak) and later degraded back to the ceiling never re-logged the one operator-actionable message. The flag now resets with the clean-streak escalation reset. - **Removed the unreachable "already suspended, extend" branch** — no sleeps occur while suspended, so `_lateWakes` stays 0 and every suspended sample early-returns before reaching it; with the threshold clamped it can never fire. If sleep gating ever changes, the normal path handles the case by counting a fresh episode. `dev-docs/debugging-event-loop.md` updated to match (phase list + gump verdict table). ### Verification - `dotnet build` clean (0 warnings) both normally and with `-p:EventLoopProfiling=true` (the snapshot phase only becomes live IL under the profiling flag). --- .../Server/Diagnostics/EventLoopProfiler.cs | 3 +- Projects/Server/Main.cs | 42 ++++++++++++++----- .../Network/NetState/NetState.Network.cs | 2 +- Projects/UOContent/Gumps/AdminGump.cs | 3 +- dev-docs/debugging-event-loop.md | 5 ++- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/Projects/Server/Diagnostics/EventLoopProfiler.cs b/Projects/Server/Diagnostics/EventLoopProfiler.cs index a3fd157f5..615a84afd 100644 --- a/Projects/Server/Diagnostics/EventLoopProfiler.cs +++ b/Projects/Server/Diagnostics/EventLoopProfiler.cs @@ -26,6 +26,7 @@ public enum LoopPhase TimerSlice, NetworkSlice, LoopTasks, + WorldSnapshot, } /// @@ -42,7 +43,7 @@ public enum LoopPhase /// public static class EventLoopProfiler { - public const int PhaseCount = 5; + public const int PhaseCount = 6; private const int RingSize = 900; private const long SampleIntervalMs = 1000; diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index eb95e6d4e..620d67c28 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -57,6 +57,12 @@ public static class Core /// public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs; + /// + /// True when idle sleeping was disabled at startup because the host cannot honor short + /// waits, overriding whatever server.eventLoopIdleWaitMs was configured to. + /// + public static bool IdleSleepUnsupported { get; private set; } + /// /// Whether idle sleeping is currently suspended because the host returned waits late. /// @@ -129,18 +135,13 @@ public static class Core return; } - // Already suspended: extend rather than counting a fresh backoff episode. - if (_tickCount - _idleSleepSuspendedUntil < 0) - { - _idleSleepSuspendedUntil = _tickCount + _currentBackoffMs; - return; - } - - // A long clean streak resets the escalation. Gated on the count rather than a + // A long clean streak resets the escalation, re-arming the ceiling Error so a host that + // recovers and later degrades again gets re-reported. Gated on the count rather than a // "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) { _consecutiveBackoffs = 0; + _loggedBackoffCeiling = false; } _currentBackoffMs = Math.Min(BackoffBaseMs << Math.Min(_consecutiveBackoffs, BackoffMaxShift), BackoffMaxMs); @@ -553,11 +554,29 @@ public static class Core ServerConfiguration.Load(); // 0 disables idle sleeping entirely (full-core spin, zero scheduling overhead). - _eventLoopIdleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); + var idleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); + if (idleWaitMs < 0) + { + logger.Warning( + "server.eventLoopIdleWaitMs {Value} is negative; using 0 (idle sleeping disabled)", + idleWaitMs + ); + } + + _eventLoopIdleWaitMs = Math.Max(0, idleWaitMs); // 16ms-budget misses per second before idle sleeping backs off. Raise to tolerate a // jittery host; set very high to disable the backoff. - _lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); + var lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); + if (lateWakeThreshold < 0) + { + logger.Warning( + "server.lateWakeThreshold {Value} is negative; using 0", + lateWakeThreshold + ); + } + + _lateWakeThreshold = Math.Max(0, lateWakeThreshold); var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); @@ -621,6 +640,7 @@ public static class Core "resolution failed). Idle sleeping is disabled. The loop will spin instead, using a full core." ); + IdleSleepUnsupported = true; _eventLoopIdleWaitMs = 0; } @@ -672,8 +692,10 @@ public static class Core if (_performSnapshot) { + EventLoopProfiler.PhaseStart(LoopPhase.WorldSnapshot); // Return value is the offset that can be used to fix timers that should drift World.Snapshot(_snapshotPath); + EventLoopProfiler.PhaseEnd(LoopPhase.WorldSnapshot); _performSnapshot = false; } diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 9cb7b85fb..f69f92076 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -87,7 +87,7 @@ public partial class NetState /// internal static bool IsIdle => _throttled.Count == 0 && _throttledPending.Count == 0 && - _flushPending.Count == 0 && _disposed.Count == 0; + _flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0; /// /// Gets the listening addresses that the server is bound to. diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 130d031fc..a4fbe0f18 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -227,7 +227,8 @@ namespace Server.Gumps } case AdminGumpPage.Information_Perf: { - var loopStatus = Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" : + var loopStatus = Core.IdleSleepUnsupported ? "Spinning - host cannot honor short waits" : + Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" : Core.IdleSleepSuspended ? "Sleep suspended - host returning waits late" : "Healthy"; AddLabel(20, 130, LabelHue, "Event Loop:"); diff --git a/dev-docs/debugging-event-loop.md b/dev-docs/debugging-event-loop.md index 5c5ea3b63..8b5fdcf36 100644 --- a/dev-docs/debugging-event-loop.md +++ b/dev-docs/debugging-event-loop.md @@ -8,7 +8,8 @@ funnel in order; most incidents resolve before the last step. Do not start with Every second of the main thread's wall time goes to exactly one of four places: 1. **Work** — the loop's phases: mobile deltas, item deltas, timer callbacks (`Timer.Slice`), - network processing (`NetState.Slice`), posted tasks (`LoopContext`). + network processing (`NetState.Slice`), posted tasks (`LoopContext`), world snapshots + (`WorldSnapshot` — the on-loop portion of a save). 2. **Sleep** — idle blocking in `NetState.WaitForCompletion`, bounded by the next timer tick and `server.eventLoopIdleWaitMs`. 3. **GC pauses** — land inside whichever phase (or sleep) was running. @@ -27,7 +28,7 @@ No build changes needed. Three signals exist, all actionable: |---|---|---| | Startup error: *host cannot honour short waits* | No high-resolution timer and `timeBeginPeriod` failed. Very old or unusual Windows. | Nothing is wrong with the server; it spins and uses a full core. Upgrade the OS or accept the core. | | Warning: *host returned a Nms idle wait late* + sleeping suspended | The OS did not reschedule the process promptly after a 1–2ms wait. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | -| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` | Same as above. | +| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` / `Spinning - host cannot honor short waits` | Same as above; the last verdict is the startup error's state, not a config choice. | If none of these fired and the shard still feels laggy, the cause is work, GC, or something a boot-time signal cannot see. Continue. From c1442aff3e15cb817a0b7ef5239d9b62e650697e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:01:29 -0700 Subject: [PATCH 37/64] fix: Stop treasure chest guardian spawn farming via stack splits (#2568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Players reported an exploit: decipher a treasure map, then run a ClassicUO/Razor organizer agent that pulls the gold out of the chest in small amounts. Each pull spawned more monsters, turning one chest into an unbounded farmable spawn generator. ### Root cause `TreasureMapChest.OnItemLifted` grants a 10% guardian spawn roll per first-time-lifted item, deduplicated by the instance-keyed `_lifted` set. But a partial lift goes through `Mobile.LiftItemDupe`, which re-adds the stack remainder to the chest as a **brand-new item instance** (engine-side `AddItem`, bypassing the `CheckHold` block on refilling). Every subsequent pull lifts an instance the `_lifted` set has never seen, so each one re-rolls the 10% spawn chance: - A level 4 chest holds 4,000 gold → pulled coin by coin, ~400 spawned creatures (plus more from reagent stacks), hands-free, per chest. - Spawns use `guardian: false`, so nothing tracks or caps them. - Legit full-stack looting yields roughly 5–8 bonus spawns per chest for comparison. The code is inherited from RunUO, so descendant shards likely share the hole. ### Fix Mark every item that enters the chest **after the initial fill** as already lifted, via an `OnItemAdded` override gated by a non-serialized `_filled` flag (set at the end of the constructor and in `[AfterDeserialization]`). Ordering makes this exact: `LiftItemDupe` re-adds the remainder *before* the chest's `OnItemLifted` runs, so the lifted original still gets its one legitimate roll while the remainder is pre-marked. This also covers packing items *into* the chest (e.g., merging gold back in to lift it out again) and bounce-backs — anything not part of the original loot can never grant a spawn roll. ### Tests - `PartialLift_MarksSplitRemainderAsLifted` — drives the real `Mobile.Lift` path with a 1-coin pull and asserts the split remainder is marked (failed before the fix). - `ItemAddedAfterFill_IsMarkedLifted` — post-fill additions are marked (failed before the fix). - `OriginalFillLoot_IsNotMarkedLifted` — original loot keeps spawn-roll eligibility. Full `UOContent.Tests` suite: 701 passed. --- .../TreasureMapChestLiftTests.cs | 111 ++++++++++++++++++ .../Items/Containers/TreasureMapChest.cs | 64 ++++++++-- .../Server.Items.TreasureMapChest.v3.json | 57 +++++++++ 3 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs new file mode 100644 index 000000000..f9b8763e7 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs @@ -0,0 +1,111 @@ +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class TreasureMapChestLiftTests +{ + // Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and + // DetectHidden (1000-2400, 500) test areas. + + [Fact] + public void PartialLift_MarksSplitRemainderAsLifted() + { + using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires + var map = Map.Felucca; + var location = new Point3D(5000, 600, 0); + var player = CreatePlayerMobile(map, location); + var chest = new TreasureMapChest(1); + + try + { + chest.MoveToWorld(location, map); + chest.Locked = false; + + var gold = FindGold(chest, null); + Assert.NotNull(gold); + + player.Lift(gold, 1, out var rejected, out _); + Assert.False(rejected); + + // The stack split re-adds the remainder as a brand-new item. It must count as + // already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll. + var remainder = FindGold(chest, gold); + Assert.NotNull(remainder); + Assert.Contains(remainder, chest.Lifted); + Assert.Contains(gold, chest.Lifted); + } + finally + { + player.Holding?.Delete(); + player.Delete(); + chest.Delete(); + } + } + + [Fact] + public void ItemAddedAfterFill_IsMarkedLifted() + { + using var rng = new PredictableRandom(10); + var chest = new TreasureMapChest(1); + var packed = new Gold(500); + + try + { + // Anything entering the chest after the initial fill (packed-back gold, split + // remainders, GM drops) was never part of the original loot and must not + // grant spawn rolls when lifted back out. + chest.DropItem(packed); + + Assert.Contains(packed, chest.Lifted); + } + finally + { + chest.Delete(); + } + } + + [Fact] + public void OriginalFillLoot_IsNotMarkedLifted() + { + using var rng = new PredictableRandom(10); + var chest = new TreasureMapChest(1); + + try + { + // The original loot must stay roll-eligible for its first lift. + Assert.True(chest.Lifted == null || chest.Lifted.Count == 0); + } + finally + { + chest.Delete(); + } + } + + private static Gold FindGold(TreasureMapChest chest, Gold except) + { + var items = chest.Items; + + for (var i = 0; i < items.Count; i++) + { + if (items[i] is Gold gold && gold != except) + { + return gold; + } + } + + return null; + } + + private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, map); + return mobile; + } +} diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index b59911a8a..94cbf7fad 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,10 +9,11 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] + [CanBeNull] [SerializableField(0, setter: "private")] private List _guardians; @@ -43,10 +44,14 @@ public partial class TreasureMapChest : LockableContainer } [Tidy] + [CanBeNull] [SerializableField(5, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private HashSet _lifted; + // False only while the constructor fills the chest; deserialized chests are always filled. + private bool _filled; + [Constructible] public TreasureMapChest(int level) : this(null, level) { @@ -58,10 +63,10 @@ public partial class TreasureMapChest : LockableContainer _level = level; _temporary = temporary; - _guardians = []; _expireTimer = Timer.DelayCall(TimeSpan.FromHours(3.0), Delete); Fill(this, level); + _filled = true; } public override int LabelNumber => 3000541; @@ -182,7 +187,6 @@ public partial class TreasureMapChest : LockableContainer 2 => 76, 3 => 84, 4 => 92, - 5 => 100, _ => 100 }; @@ -300,14 +304,17 @@ public partial class TreasureMapChest : LockableContainer if (_level == 0 && from.AccessLevel < AccessLevel.GameMaster) { - for (var i = 0; i < _guardians.Count; i++) + if (_guardians.Count > 0) { - var m = _guardians[i]; - if (m.Alive) + for (var i = 0; i < _guardians.Count; i++) { - // You must first kill the guardians before you may open this chest. - from.SendLocalizedMessage(1046448); - return true; + var m = _guardians[i]; + if (m.Alive) + { + // You must first kill the guardians before you may open this chest. + from.SendLocalizedMessage(1046448); + return true; + } } } @@ -361,6 +368,17 @@ public partial class TreasureMapChest : LockableContainer public override bool CheckLift(Mobile from, Item item, ref LRReason reject) => CheckLoot(from, true) && base.CheckLift(from, item, ref reject); + public override void OnItemAdded(Item item) + { + base.OnItemAdded(item); + + if (_filled) + { + _lifted ??= []; + _lifted.Add(item); + } + } + public override void OnItemLifted(Mobile from, Item item) { var notYetLifted = _lifted?.Contains(item) != true; @@ -393,8 +411,6 @@ public partial class TreasureMapChest : LockableContainer private void Deserialize(IGenericReader reader, int version) { - _guardians = []; - _owner = reader.ReadEntity(); _level = reader.ReadInt(); var expireTimerNext = reader.ReadDeltaTime(); @@ -402,12 +418,34 @@ public partial class TreasureMapChest : LockableContainer _lifted = reader.ReadEntitySet(); } - [AfterDeserialization(false)] + private void MigrateFrom(V2Content content) + { + _guardians = content.Guardians; + if (_guardians.Count == 0) + { + _guardians = null; + } + _temporary = content.Temporary; + _owner = content.Owner; + _level = content.Level; + _lifted = content.Lifted; + if (_lifted.Count == 0) + { + _lifted = null; + } + + var expireTimerDelay = content.ExpireTimerDelay; + DeserializeExpireTimer(expireTimerDelay); + } + + [AfterDeserialization] private void AfterDeserialization() { + _filled = true; + if (_expireTimer == null) { - Delete(); + Timer.DelayCall(Delete); } } diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json new file mode 100644 index 000000000..805bbb6d1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json @@ -0,0 +1,57 @@ +{ + "version": 3, + "type": "Server.Items.TreasureMapChest", + "properties": [ + { + "name": "Guardians", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Temporary", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ExpireTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@TimerDrift" + ] + }, + { + "name": "Lifted", + "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file From 1bc83339bb5705e3d9aee02f42d78df048013a83 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:06:43 -0700 Subject: [PATCH 38/64] fix: Fixes guardian lazy check on Treasure Map Chests (#2569) ### Summary Fixes a crash bug from the lazy check on treasure map chest guardians. --- Projects/UOContent/Items/Containers/TreasureMapChest.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index 94cbf7fad..e0486182d 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -304,7 +304,7 @@ public partial class TreasureMapChest : LockableContainer if (_level == 0 && from.AccessLevel < AccessLevel.GameMaster) { - if (_guardians.Count > 0) + if (_guardians != null) { for (var i = 0; i < _guardians.Count; i++) { @@ -381,10 +381,8 @@ public partial class TreasureMapChest : LockableContainer public override void OnItemLifted(Mobile from, Item item) { - var notYetLifted = _lifted?.Contains(item) != true; from.RevealingAction(); - - if (notYetLifted) + if (_lifted?.Contains(item) != true) { _lifted ??= []; _lifted.Add(item); From 5ce0f1e92b38d7b5cea666ee0d8e2a88b6bf18cc Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:12:54 -0700 Subject: [PATCH 39/64] fix: Require BOD combine items to be player-crafted (#2573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `SmallBOD.EndCombine` validates an item's **type**, **material** and **exceptional quality**, but never checks that the item was actually crafted by a player. Any item matching the request is accepted, including one bought straight from an NPC vendor. https://github.com/modernuo/ModernUO/blob/main/Projects/UOContent/Engines/Bulk%20Orders/SmallBOD.cs#L117-L168 Where a vendor stocks a type a BOD can request, a player can fill the deed by buying the items instead of crafting them, and pocket the reward gold for the difference. Tailoring is the clearest case. `SmallTailorBOD.CreateRandomFor` guarantees `Material = None` and `RequireExceptional = false` below 70.1 skill, so the rolled deed asks for plain cloth items — and tailor vendors stock several of those directly. A qty-20 Bandana BOD can be filled entirely from vendor stock for a small fraction of the reward gold, with no crafting and no material cost. The same shape applies anywhere else a vendor-sold type overlaps a requestable BOD type; tailoring is simply where the low-skill deed generator and the vendor inventory overlap most. ## Fix Add a `PlayerConstructed` check alongside the existing material and quality checks. ```csharp var playerConstructed = armor?.PlayerConstructed ?? clothing?.PlayerConstructed ?? weapon?.PlayerConstructed ?? false; if (!playerConstructed) { from.SendLocalizedMessage(1045169); // The item is not in the request. } ``` This follows the pattern already used in `Engines/Craft/Core/Resmelt.cs` (L98-L100, L155-L160) to distinguish crafted from store-bought items, and reuses the same null-coalescing chain style as the adjacent `GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None)` line directly above it. `PlayerConstructed` is already set in `OnCraft` and serialized on all three bases (`BaseArmor`, `BaseWeapon`, `BaseClothing`), so the flag survives restarts and no serialization change is needed. ## Open question — the message There is no dedicated cliloc for "this item must be crafted", so I reused **1045169** (*"The item is not in the request."*). It is arguably accurate — a vendor-bought item genuinely is not what the deed asked for — but it is not precise, and a player who does not know the rule will find it confusing. I would rather flag this than invent a string. If there is a better cliloc, I am happy to switch it. ## Testing `dotnet build Projects/UOContent/UOContent.csproj` — **0 errors, 0 warnings**. Not covered: I have not added an automated test, as I could not find existing coverage for `EndCombine` to extend. Happy to add one if you would like it, with a pointer to the preferred pattern. ## Compatibility note Any *already-existing* vendor-bought item in a player's possession will now be rejected by a BOD. That is the intended behaviour, but it is a visible change for anyone mid-deed. Worth a line in release notes. --- Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs index 6356780de..7742a9935 100644 --- a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs @@ -136,8 +136,14 @@ public abstract partial class SmallBOD : BaseBOD else { var material = GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None); + var playerConstructed = armor?.PlayerConstructed ?? clothing?.PlayerConstructed ?? + weapon?.PlayerConstructed ?? false; - if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) + if (!playerConstructed) + { + from.SendLocalizedMessage(1045169); // The item is not in the request. + } + else if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) { from.SendLocalizedMessage(1045168); // The item is not made from the requested ore. } From bd79cb775990343b6a3f98800cedee576fe42b83 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:34:59 -0700 Subject: [PATCH 40/64] fix: Consolidate PlayerConstructed onto Item, stamped by the craft system (#2574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2573. That change made `SmallBOD.EndCombine` require a player-crafted item, but it could only read provenance off `BaseArmor`, `BaseWeapon` and `BaseClothing`, because those are the only three classes that track it — hence the hand-enumerated `armor?.PlayerConstructed ?? clothing?.PlayerConstructed ?? weapon?.PlayerConstructed ?? false`. The gap is structural rather than cosmetic. `PlayerConstructed` is set inside each base's `OnCraft`, so it can only ever reach types implementing `ICraftable`. Most craftables do not — the tinkering catalogue alone is largely plain `Item` subclasses — so any rule keyed on "was this actually crafted" has nothing to key on for those types. ## What changed Provenance moves to `Item` and is stamped centrally in `CraftItem`, immediately after the item is constructed and before the `ICraftable` dispatch, covering both the AOS and T2A craft paths. The three `OnCraft` overrides drop their now-redundant assignment and inherit `Item`'s property, so no call site outside them changes — `Resmelt` and `SalvageBag` still read `armor.PlayerConstructed` and still compile unchanged. `SmallBOD`'s three-way null-coalescing chain collapses to `item.PlayerConstructed`. `OnCraft` is only ever invoked from `CraftItem` (the other three call sites are `base.OnCraft` chaining), so removing those assignments has no other reachable effect. ## Storage cost: none `Item`'s `SaveFlag` word is written as a fixed-width `int`, not an encoded one, so occupying bit `0x08000000` changes no record lengths. Items that are not player-constructed serialize byte for byte as before, and crafted ones differ by a single bit in a field already being written. `Item` itself needs no version bump: a bare `SaveFlag` bit is self-describing, so records written before it existed lack it and read `false`. ## Version bumps The three content classes do need one, since removing a serialized field changes their layout: | Class | Version | Field removed | |---|---|---| | `BaseArmor` | 9 → 10 | 24 (was last, nothing renumbered) | | `BaseClothing` | 7 → 8 | 7 (fields 8–10 shift down) | | `BaseWeapon` | 10 → 11 | 26 (fields 27–30 shift down) | Each gets a `MigrateFrom` for its previous version that assigns the old bool to the inherited property, so existing crafted armour, weapons and clothing keep their provenance across the upgrade. `Item.Deserialize` runs first and reads the absent bit as `false`, then the migration overwrites it — the generated `Deserialize` calls `base.Deserialize` before dispatching, so the ordering holds. `BaseWeapon` had no migrations file and gains one. The renumbering is not stylistic: the generator requires contiguous field ordering and rejects a hole with `SG3005: Expected field 'Crafter' with order 7 but found 8`. New schema JSONs (`BaseArmor.v10`, `BaseClothing.v8`, `BaseWeapon.v11`) are generated by `ModernUOSchemaGenerator` and committed alongside. ## One thing worth a second opinion The new property is a plain auto-property on `Item`, so it does not call `this.MarkDirty()` the way the codegen setters it replaces did. `MarkDirty` is currently a no-op (`// TODO: Add dirty tracking back`) and no property in `Item.cs` calls it, so this matches the file as it stands — but it is worth noting if dirty tracking comes back. ## Verification Full solution builds in Release with 0 errors and 0 warnings; 1516 tests pass (815 `Server.Tests`, 701 `UOContent.Tests`). --- Projects/Server/Items/Item.cs | 16 +- .../UOContent/Engines/Bulk Orders/SmallBOD.cs | 4 +- .../UOContent/Engines/Craft/Core/CraftItem.cs | 6 + .../Items/Armor/BaseArmor.Migrations.cs | 32 ++- Projects/UOContent/Items/Armor/BaseArmor.cs | 10 +- .../Items/Clothing/BaseClothing.Migrations.cs | 18 +- .../UOContent/Items/Clothing/BaseClothing.cs | 25 +- .../Items/Weapons/BaseWeapon.Migrations.cs | 42 +++ .../UOContent/Items/Weapons/BaseWeapon.cs | 31 +-- .../Server.Items.BaseArmor.v10.json | 207 +++++++++++++++ .../Server.Items.BaseClothing.v8.json | 90 +++++++ .../Server.Items.BaseWeapon.v11.json | 246 ++++++++++++++++++ 12 files changed, 675 insertions(+), 52 deletions(-) create mode 100644 Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 64cef9526..61e6f644f 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -749,6 +749,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public static bool ScissorCopyLootType { get; set; } + /// + /// True when the item was produced by the crafting system rather than bought or looted. + /// + [CommandProperty(AccessLevel.GameMaster)] + public bool PlayerConstructed { get; set; } + [CommandProperty(AccessLevel.GameMaster)] public bool QuestItem { @@ -979,6 +985,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert flags |= SaveFlag.ImplFlags; } + if (PlayerConstructed) + { + flags |= SaveFlag.PlayerConstructed; + } + writer.Write((int)flags); /* begin last moved time optimization */ @@ -2854,6 +2865,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt(); } + PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed); + if (m_Map != null && m_Parent == null) { m_Map.OnEnter(this); @@ -4380,6 +4393,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert HeldBy = 0x00800000, IntWeight = 0x01000000, SavedFlags = 0x02000000, - NullWeight = 0x04000000 + NullWeight = 0x04000000, + PlayerConstructed = 0x08000000 } } diff --git a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs index 7742a9935..746da8687 100644 --- a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs @@ -136,10 +136,8 @@ public abstract partial class SmallBOD : BaseBOD else { var material = GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None); - var playerConstructed = armor?.PlayerConstructed ?? clothing?.PlayerConstructed ?? - weapon?.PlayerConstructed ?? false; - if (!playerConstructed) + if (!item.PlayerConstructed) { from.SendLocalizedMessage(1045169); // The item is not in the request. } diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index 5a31aec3c..e99760bbc 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -1451,6 +1451,9 @@ namespace Server.Engines.Craft if (item != null) { + // Stamped here, not in OnCraft: most craftables do not implement ICraftable. + item.PlayerConstructed = true; + if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); @@ -1742,6 +1745,9 @@ namespace Server.Engines.Craft if (item != null) { + // Stamped here, not in OnCraft: most craftables do not implement ICraftable. + item.PlayerConstructed = true; + if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); diff --git a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs index 3135850a9..f01bfad16 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs @@ -4,6 +4,36 @@ namespace Server.Items; public partial class BaseArmor { + // PlayerConstructed moved onto Item + private void MigrateFrom(V9Content content) + { + _attributes = content.Attributes ?? AttributesDefaultValue(); + _armorAttributes = content.ArmorAttributes ?? ArmorAttributesDefaultValue(); + _physicalBonus = content.PhysicalBonus ?? 0; + _fireBonus = content.FireBonus ?? 0; + _coldBonus = content.ColdBonus ?? 0; + _poisonBonus = content.PoisonBonus ?? 0; + _energyBonus = content.EnergyBonus ?? 0; + _identified = content.Identified; + _maxHitPoints = content.MaxHitPoints ?? 0; + _hitPoints = content.HitPoints ?? 0; + _crafter = content.Crafter; + _quality = content.Quality ?? ArmorQuality.Regular; + _durability = content.Durability ?? ArmorDurabilityLevel.Regular; + _protectionLevel = content.ProtectionLevel ?? ArmorProtectionLevel.Regular; + _resource = content.Resource ?? DefaultResource; + _armorBase = content.BaseArmorRating ?? -1; + _strBonus = content.StrBonus ?? -1; + _dexBonus = content.DexBonus ?? -1; + _intBonus = content.IntBonus ?? -1; + _strReq = content.StrRequirement ?? -1; + _dexReq = content.DexRequirement ?? -1; + _intReq = content.IntRequirement ?? -1; + _meditate = content.MeditationAllowance ?? (AMA)(-1); + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + PlayerConstructed = content.PlayerConstructed; + } + private void MigrateFrom(V8Content content) { _attributes = content.Attributes ?? AttributesDefaultValue(); @@ -29,7 +59,7 @@ public partial class BaseArmor _intReq = content.IntRequirement ?? -1; _meditate = content.MeditationAllowance ?? (AMA)(-1); _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - _playerConstructed = content.PlayerConstructed; + PlayerConstructed = content.PlayerConstructed; } // Version 7 (pre-codegen) diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 7f01648a0..f8c8c02bf 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -12,7 +12,7 @@ using AMT = Server.Items.ArmorMaterialType; namespace Server.Items { - [SerializationGenerator(9, false)] + [SerializationGenerator(10, false)] public abstract partial class BaseArmor : Item, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem, IIdentifiable { @@ -144,13 +144,6 @@ namespace Server.Items [SerializableFieldDefault(23)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); - [SerializableField(24)] - [SerializedCommandProperty(AccessLevel.GameMaster)] - public bool _playerConstructed; - - [SerializableFieldSaveFlag(24)] - private bool ShouldSerializePlayerConstructed() => _playerConstructed; - private FactionItem m_FactionState; public BaseArmor(int itemID) : base(itemID) @@ -570,7 +563,6 @@ namespace Server.Items var resourceType = typeRes ?? craftItem.Resources[0].ItemType; Resource = CraftResources.GetFromType(resourceType); - PlayerConstructed = true; Identified = true; var context = craftSystem.GetContext(from); diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs index e267cd1aa..1c5a22506 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs @@ -2,6 +2,22 @@ namespace Server.Items; public partial class BaseClothing { + // PlayerConstructed moved onto Item + private void MigrateFrom(V7Content content) + { + _resource = content.Resource ?? DefaultResource; + _attributes = content.Attributes ?? AttributesDefaultValue(); + _clothingAttributes = content.ClothingAttributes ?? ClothingAttributesDefaultValue(); + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + _resistances = content.Resistances ?? ResistancesDefaultValue(); + _maxHitPoints = content.MaxHitPoints ?? 0; + _hitPoints = content.HitPoints ?? 0; + PlayerConstructed = content.PlayerConstructed; + _crafter = content.Crafter; + _quality = content.Quality ?? ClothingQuality.Regular; + _strReq = content.StrRequirement ?? -1; + } + private void MigrateFrom(V6Content content) { _resource = content.RawResource ?? DefaultResource; @@ -10,7 +26,7 @@ public partial class BaseClothing _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); _resistances = content.Resistances ?? ResistancesDefaultValue(); _maxHitPoints = content.MaxHitPoints ?? 0; - _playerConstructed = content.PlayerConstructed; + PlayerConstructed = content.PlayerConstructed; Timer.DelayCall((item, crafter) => item._crafter = crafter?.RawName, this, content.Crafter); _quality = content.Quality ?? ClothingQuality.Regular; _strReq = content.StrRequirement ?? -1; diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 725eed100..7a699c4a9 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -22,7 +22,7 @@ namespace Server.Items int MaxArcaneCharges { get; set; } } - [SerializationGenerator(7, false)] + [SerializationGenerator(8, false)] public abstract partial class BaseClothing : Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem { @@ -82,30 +82,23 @@ namespace Server.Items [SerializableFieldSaveFlag(5)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; + [InvalidateProperties] [SerializableField(7)] [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _playerConstructed; + private string _crafter; [SerializableFieldSaveFlag(7)] - private bool ShouldSerializePlayerConstructed() => _playerConstructed; + private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(8)] [SerializedCommandProperty(AccessLevel.GameMaster)] - private string _crafter; - - [SerializableFieldSaveFlag(8)] - private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); - - [InvalidateProperties] - [SerializableField(9)] - [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; - [SerializableFieldSaveFlag(9)] + [SerializableFieldSaveFlag(8)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - // Field 10 + // Field 9 private int _strReq = -1; private FactionItem _factionState; @@ -139,7 +132,7 @@ namespace Server.Items } } - [SerializableProperty(10, useField: nameof(_strReq))] + [SerializableProperty(9, useField: nameof(_strReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -152,7 +145,7 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(10)] + [SerializableFieldSaveFlag(9)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -207,8 +200,6 @@ namespace Server.Items Hue = resHue; } - PlayerConstructed = true; - var context = craftSystem.GetContext(from); if (context?.DoNotColor == true) diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs new file mode 100644 index 000000000..98abd7aad --- /dev/null +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -0,0 +1,42 @@ +using Server.Engines.Craft; + +namespace Server.Items; + +public partial class BaseWeapon +{ + // PlayerConstructed moved onto Item + private void MigrateFrom(V10Content content) + { + _damageLevel = content.DamageLevel ?? WeaponDamageLevel.Regular; + _accuracyLevel = content.AccuracyLevel ?? WeaponAccuracyLevel.Regular; + _durabilityLevel = content.DurabilityLevel ?? WeaponDurabilityLevel.Regular; + _quality = content.Quality ?? WeaponQuality.Regular; + _hitPoints = content.HitPoints ?? 0; + _maxHitPoints = content.MaxHitPoints ?? 0; + _slayer = content.Slayer ?? SlayerName.None; + _poison = content.Poison; + _poisonCharges = content.PoisonCharges ?? 0; + _crafter = content.Crafter; + _identified = content.Identified; + _strRequirement = content.StrRequirement ?? -1; + _dexRequirement = content.DexRequirement ?? -1; + _intRequirement = content.IntRequirement ?? -1; + _minDamage = content.MinDamage ?? -1; + _maxDamage = content.MaxDamage ?? -1; + _hitSound = content.HitSound ?? -1; + _missSound = content.MissSound ?? -1; + _speed = content.Speed ?? -1; + _maxRange = content.MaxRange ?? -1; + _skill = content.Skill ?? (SkillName)(-1); + _type = content.Type ?? (WeaponType)(-1); + _animation = content.Animation ?? (WeaponAnimation)(-1); + _resource = content.Resource ?? CraftResource.Iron; + _attributes = content.Attributes ?? AttributesDefaultValue(); + _weaponAttributes = content.WeaponAttributes ?? WeaponAttributesDefaultValue(); + PlayerConstructed = content.PlayerConstructed; + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + _slayer2 = content.Slayer2 ?? SlayerName.None; + _aosElementDamages = content.AosElementDamages ?? AosElementAttributesDefaultValue(); + _engravedText = content.EngravedText; + } +} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index b28f0d371..6aad89d60 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -27,7 +27,7 @@ public interface ISlayer SlayerName Slayer2 { get; set; } } -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public abstract partial class BaseWeapon : Item, IWeapon, IFactionItem, ICraftable, ISlayer, IDurability, IAosItem, IIdentifiable { @@ -141,53 +141,45 @@ public abstract partial class BaseWeapon [SerializableFieldDefault(25)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); - [SerializableField(26)] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _playerConstructed; - - [SerializableFieldSaveFlag(26)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ShouldSerializePlayerConstructed() => _playerConstructed; - [SerializedIgnoreDupe] - [SerializableField(27, setter: "private")] + [SerializableField(26, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(27)] + [SerializableFieldSaveFlag(26)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(27)] + [SerializableFieldDefault(26)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(28)] + [SerializableField(27)] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; - [SerializableFieldSaveFlag(28)] + [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] - [SerializableField(29, setter: "private")] + [SerializableField(28, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; - [SerializableFieldSaveFlag(29)] + [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; - [SerializableFieldDefault(29)] + [SerializableFieldDefault(28)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(30)] + [SerializableField(29)] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; - [SerializableFieldSaveFlag(30)] + [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -674,7 +666,6 @@ public abstract partial class BaseWeapon Crafter = from.RawName; } - PlayerConstructed = true; Identified = true; var resourceType = typeRes ?? craftItem.Resources[0].ItemType; diff --git a/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json new file mode 100644 index 000000000..d7844e116 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json @@ -0,0 +1,207 @@ +{ + "version": 10, + "type": "Server.Items.BaseArmor", + "properties": [ + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "ArmorAttributes", + "type": "Server.AosArmorAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "PhysicalBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Identified", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Quality", + "type": "Server.Items.ArmorQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Durability", + "type": "Server.Items.ArmorDurabilityLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "ProtectionLevel", + "type": "Server.Items.ArmorProtectionLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "BaseArmorRating", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StrBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DexBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "IntBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DexRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "IntRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "MeditationAllowance", + "type": "Server.Items.ArmorMeditationAllowance", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json b/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json new file mode 100644 index 000000000..09d5e07be --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json @@ -0,0 +1,90 @@ +{ + "version": 8, + "type": "Server.Items.BaseClothing", + "properties": [ + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "ClothingAttributes", + "type": "Server.AosArmorAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "Resistances", + "type": "Server.AosElementAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Quality", + "type": "Server.Items.ClothingQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json b/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json new file mode 100644 index 000000000..009666433 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json @@ -0,0 +1,246 @@ +{ + "version": 11, + "type": "Server.Items.BaseWeapon", + "properties": [ + { + "name": "DamageLevel", + "type": "Server.Items.WeaponDamageLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "AccuracyLevel", + "type": "Server.Items.WeaponAccuracyLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "DurabilityLevel", + "type": "Server.Items.WeaponDurabilityLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Quality", + "type": "Server.Items.WeaponQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Slayer", + "type": "Server.Items.SlayerName", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Poison", + "type": "Server.Poison", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Poison" + ] + }, + { + "name": "PoisonCharges", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Identified", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DexRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IntRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HitSound", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MissSound", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Speed", + "type": "float", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxRange", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Skill", + "type": "Server.SkillName", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Type", + "type": "Server.Items.WeaponType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Animation", + "type": "Server.Items.WeaponAnimation", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "WeaponAttributes", + "type": "Server.AosWeaponAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "Slayer2", + "type": "Server.Items.SlayerName", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "AosElementDamages", + "type": "Server.AosElementAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "EngravedText", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file From 55ac2c3d989956b67b6ab25d2b32cf3933cee2d4 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:53 -0700 Subject: [PATCH 41/64] refactor: Move legacy deserialization into the .Migrations.cs partials (#2575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2574, which added a `.Migrations.cs` partial to `BaseWeapon`. Pure relocation — no behaviour change. ## The inconsistency `BaseArmor` and `BaseClothing` already kept their pre-codegen `Deserialize(reader, version)` in a `.Migrations.cs` partial, but left the `OldSaveFlag` enum and the `GetSaveFlag` helper behind in the main class file — even though every call site is in the partial: | Class | `Deserialize` | `GetSaveFlag` / `OldSaveFlag` | Call sites outside the partial | |---|---|---|---| | `BaseArmor` | already in partial | in main file | 0 of 26 | | `BaseClothing` | already in partial | in main file | 0 of 12 | | `BaseWeapon` | in main file | in main file | — | `BaseWeapon` had all three still inline, with its new `.Migrations.cs` holding only a `MigrateFrom`. ## After All three follow the same layout: `MigrateFrom` newest to oldest, then the pre-codegen `Deserialize`, then `GetSaveFlag`, then `OldSaveFlag`. That moves ~290 lines of legacy read path out of `BaseWeapon.cs` — the file that needed it most at ~3,900 lines — and leaves the main class files describing only how the type behaves today. ## Reviewing this The diff is large and almost entirely noise, so it is probably not worth reading line by line. Two checks are stronger: - **Nothing was lost or altered.** Across each `.cs` / `.Migrations.cs` pair, the multiset of non-blank source lines is identical to `main` except for one added comment (below). The relocation was done mechanically and asserted against that invariant rather than by hand. - **Nothing about serialization moved with the code.** Running `ModernUOSchemaGenerator` after the move emits no new migration files. The complete set of intentional additions: - `using System;` in each of the three partials, for the `[Flags]` attribute (implicit usings are not enabled here). - `// Version 9 (pre-codegen)` above `BaseWeapon`'s moved `Deserialize`, matching the marker `BaseArmor` and `BaseClothing` already carry. Version 9 is correct because `BaseWeapon.v10.json` is its earliest migration schema, so codegen began at 10. Everything else is blank-line placement. ## Verification Full solution builds in Release with 0 errors and 0 warnings; 1516 tests pass (815 `Server.Tests`, 701 `UOContent.Tests`). --- .../Items/Armor/BaseArmor.Migrations.cs | 34 +++ Projects/UOContent/Items/Armor/BaseArmor.cs | 32 --- .../Items/Clothing/BaseClothing.Migrations.cs | 21 ++ .../UOContent/Items/Clothing/BaseClothing.cs | 18 -- .../Items/Weapons/BaseWeapon.Migrations.cs | 269 ++++++++++++++++++ .../UOContent/Items/Weapons/BaseWeapon.cs | 266 ----------------- 6 files changed, 324 insertions(+), 316 deletions(-) diff --git a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs index f01bfad16..f15a7fd6a 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs @@ -1,3 +1,4 @@ +using System; using AMA = Server.Items.ArmorMeditationAllowance; namespace Server.Items; @@ -192,4 +193,37 @@ public partial class BaseArmor PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } + + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Attributes = 0x00000001, + ArmorAttributes = 0x00000002, + PhysicalBonus = 0x00000004, + FireBonus = 0x00000008, + ColdBonus = 0x00000010, + PoisonBonus = 0x00000020, + EnergyBonus = 0x00000040, + Identified = 0x00000080, + MaxHitPoints = 0x00000100, + HitPoints = 0x00000200, + Crafter = 0x00000400, + Quality = 0x00000800, + Durability = 0x00001000, + Protection = 0x00002000, + Resource = 0x00004000, + BaseArmor = 0x00008000, + StrBonus = 0x00010000, + DexBonus = 0x00020000, + IntBonus = 0x00040000, + StrReq = 0x00080000, + DexReq = 0x00100000, + IntReq = 0x00200000, + MedAllowance = 0x00400000, + SkillBonuses = 0x00800000, + PlayerConstructed = 0x01000000 + } } diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index f8c8c02bf..6f91f3a71 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -1018,8 +1018,6 @@ namespace Server.Items (Parent as Mobile)?.Delta(MobileDelta.Armor); // Tell them armor rating has changed } - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - [AfterDeserialization] private void AfterDeserialization() { @@ -1506,35 +1504,5 @@ namespace Server.Items }; } - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Attributes = 0x00000001, - ArmorAttributes = 0x00000002, - PhysicalBonus = 0x00000004, - FireBonus = 0x00000008, - ColdBonus = 0x00000010, - PoisonBonus = 0x00000020, - EnergyBonus = 0x00000040, - Identified = 0x00000080, - MaxHitPoints = 0x00000100, - HitPoints = 0x00000200, - Crafter = 0x00000400, - Quality = 0x00000800, - Durability = 0x00001000, - Protection = 0x00002000, - Resource = 0x00004000, - BaseArmor = 0x00008000, - StrBonus = 0x00010000, - DexBonus = 0x00020000, - IntBonus = 0x00040000, - StrReq = 0x00080000, - DexReq = 0x00100000, - IntReq = 0x00200000, - MedAllowance = 0x00400000, - SkillBonuses = 0x00800000, - PlayerConstructed = 0x01000000 - } } } diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs index 1c5a22506..7c431f076 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs @@ -1,3 +1,5 @@ +using System; + namespace Server.Items; public partial class BaseClothing @@ -101,4 +103,23 @@ public partial class BaseClothing PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } + + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Resource = 0x00000001, + Attributes = 0x00000002, + ClothingAttributes = 0x00000004, + SkillBonuses = 0x00000008, + Resistances = 0x00000010, + MaxHitPoints = 0x00000020, + HitPoints = 0x00000040, + PlayerConstructed = 0x00000080, + Crafter = 0x00000100, + Quality = 0x00000200, + StrReq = 0x00000400 + } } diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 7a699c4a9..8246c8c46 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -880,8 +880,6 @@ namespace Server.Items InvalidateProperties(); } - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - [AfterDeserialization] private void AfterDeserialization() { @@ -902,21 +900,5 @@ namespace Server.Items } } - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Resource = 0x00000001, - Attributes = 0x00000002, - ClothingAttributes = 0x00000004, - SkillBonuses = 0x00000008, - Resistances = 0x00000010, - MaxHitPoints = 0x00000020, - HitPoints = 0x00000040, - PlayerConstructed = 0x00000080, - Crafter = 0x00000100, - Quality = 0x00000200, - StrReq = 0x00000400 - } } } diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs index 98abd7aad..3385598d9 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -1,3 +1,4 @@ +using System; using Server.Engines.Craft; namespace Server.Items; @@ -39,4 +40,272 @@ public partial class BaseWeapon _aosElementDamages = content.AosElementDamages ?? AosElementAttributesDefaultValue(); _engravedText = content.EngravedText; } + + // Version 9 (pre-codegen) + private void Deserialize(IGenericReader reader, int version) + { + var flags = (OldSaveFlag)reader.ReadInt(); + + if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) + { + _damageLevel = (WeaponDamageLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) + { + _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) + { + _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Quality)) + { + _quality = (WeaponQuality)reader.ReadInt(); + } + else + { + _quality = WeaponQuality.Regular; + } + + if (GetSaveFlag(flags, OldSaveFlag.Hits)) + { + _hitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) + { + _maxHitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer)) + { + _slayer = (SlayerName)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Poison)) + { + _poison = reader.ReadPoison(); + } + + if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) + { + _poisonCharges = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Crafter)) + { + Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); + } + + if (GetSaveFlag(flags, OldSaveFlag.Identified)) + { + _identified = version >= 6 || reader.ReadBool(); + } + + if (GetSaveFlag(flags, OldSaveFlag.StrReq)) + { + _strRequirement = reader.ReadInt(); + } + else + { + _strRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.DexReq)) + { + _dexRequirement = reader.ReadInt(); + } + else + { + _dexRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.IntReq)) + { + _intRequirement = reader.ReadInt(); + } + else + { + _intRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) + { + _minDamage = reader.ReadInt(); + } + else + { + _minDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) + { + _maxDamage = reader.ReadInt(); + } + else + { + _maxDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.HitSound)) + { + _hitSound = reader.ReadInt(); + } + else + { + _hitSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MissSound)) + { + _missSound = reader.ReadInt(); + } + else + { + _missSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Speed)) + { + if (version < 9) + { + _speed = reader.ReadInt(); + } + else + { + _speed = reader.ReadFloat(); + } + } + else + { + _speed = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) + { + _maxRange = reader.ReadInt(); + } + else + { + _maxRange = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Skill)) + { + _skill = (SkillName)reader.ReadInt(); + } + else + { + _skill = (SkillName)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Type)) + { + _type = (WeaponType)reader.ReadInt(); + } + else + { + _type = (WeaponType)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Animation)) + { + _animation = (WeaponAnimation)reader.ReadInt(); + } + else + { + _animation = (WeaponAnimation)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Resource)) + { + _resource = (CraftResource)reader.ReadInt(); + } + else + { + _resource = CraftResource.Iron; + } + + Attributes = new AosAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.Attributes)) + { + Attributes.Deserialize(reader); + } + + WeaponAttributes = new AosWeaponAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) + { + WeaponAttributes.Deserialize(reader); + } + + PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); + + SkillBonuses = new AosSkillBonuses(this); + + if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) + { + SkillBonuses.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) + { + _slayer2 = (SlayerName)reader.ReadInt(); + } + + AosElementDamages = new AosElementAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) + { + AosElementDamages.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) + { + _engravedText = reader.ReadString(); + } + } + + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + DamageLevel = 0x00000001, + AccuracyLevel = 0x00000002, + DurabilityLevel = 0x00000004, + Quality = 0x00000008, + Hits = 0x00000010, + MaxHits = 0x00000020, + Slayer = 0x00000040, + Poison = 0x00000080, + PoisonCharges = 0x00000100, + Crafter = 0x00000200, + Identified = 0x00000400, + StrReq = 0x00000800, + DexReq = 0x00001000, + IntReq = 0x00002000, + MinDamage = 0x00004000, + MaxDamage = 0x00008000, + HitSound = 0x00010000, + MissSound = 0x00020000, + Speed = 0x00040000, + MaxRange = 0x00080000, + Skill = 0x00100000, + Type = 0x00200000, + Animation = 0x00400000, + Resource = 0x00800000, + Attributes = 0x01000000, + WeaponAttributes = 0x02000000, + PlayerConstructed = 0x04000000, + SkillBonuses = 0x08000000, + Slayer2 = 0x10000000, + ElementalDamages = 0x20000000, + EngravedText = 0x40000000 + } } diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 6aad89d60..1f03daf63 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -3571,236 +3571,6 @@ public abstract partial class BaseWeapon } } - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - private void Deserialize(IGenericReader reader, int version) - { - var flags = (OldSaveFlag)reader.ReadInt(); - - if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) - { - _damageLevel = (WeaponDamageLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) - { - _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) - { - _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Quality)) - { - _quality = (WeaponQuality)reader.ReadInt(); - } - else - { - _quality = WeaponQuality.Regular; - } - - if (GetSaveFlag(flags, OldSaveFlag.Hits)) - { - _hitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) - { - _maxHitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer)) - { - _slayer = (SlayerName)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Poison)) - { - _poison = reader.ReadPoison(); - } - - if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) - { - _poisonCharges = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Crafter)) - { - Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); - } - - if (GetSaveFlag(flags, OldSaveFlag.Identified)) - { - _identified = version >= 6 || reader.ReadBool(); - } - - if (GetSaveFlag(flags, OldSaveFlag.StrReq)) - { - _strRequirement = reader.ReadInt(); - } - else - { - _strRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.DexReq)) - { - _dexRequirement = reader.ReadInt(); - } - else - { - _dexRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.IntReq)) - { - _intRequirement = reader.ReadInt(); - } - else - { - _intRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) - { - _minDamage = reader.ReadInt(); - } - else - { - _minDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) - { - _maxDamage = reader.ReadInt(); - } - else - { - _maxDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.HitSound)) - { - _hitSound = reader.ReadInt(); - } - else - { - _hitSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MissSound)) - { - _missSound = reader.ReadInt(); - } - else - { - _missSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Speed)) - { - if (version < 9) - { - _speed = reader.ReadInt(); - } - else - { - _speed = reader.ReadFloat(); - } - } - else - { - _speed = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) - { - _maxRange = reader.ReadInt(); - } - else - { - _maxRange = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Skill)) - { - _skill = (SkillName)reader.ReadInt(); - } - else - { - _skill = (SkillName)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Type)) - { - _type = (WeaponType)reader.ReadInt(); - } - else - { - _type = (WeaponType)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Animation)) - { - _animation = (WeaponAnimation)reader.ReadInt(); - } - else - { - _animation = (WeaponAnimation)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Resource)) - { - _resource = (CraftResource)reader.ReadInt(); - } - else - { - _resource = CraftResource.Iron; - } - - Attributes = new AosAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.Attributes)) - { - Attributes.Deserialize(reader); - } - - WeaponAttributes = new AosWeaponAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) - { - WeaponAttributes.Deserialize(reader); - } - - PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); - - SkillBonuses = new AosSkillBonuses(this); - - if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) - { - SkillBonuses.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) - { - _slayer2 = (SlayerName)reader.ReadInt(); - } - - AosElementDamages = new AosElementAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) - { - AosElementDamages.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) - { - _engravedText = reader.ReadString(); - } - } - [AfterDeserialization] private void AfterDeserialization() { @@ -3866,42 +3636,6 @@ public abstract partial class BaseWeapon } } - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - DamageLevel = 0x00000001, - AccuracyLevel = 0x00000002, - DurabilityLevel = 0x00000004, - Quality = 0x00000008, - Hits = 0x00000010, - MaxHits = 0x00000020, - Slayer = 0x00000040, - Poison = 0x00000080, - PoisonCharges = 0x00000100, - Crafter = 0x00000200, - Identified = 0x00000400, - StrReq = 0x00000800, - DexReq = 0x00001000, - IntReq = 0x00002000, - MinDamage = 0x00004000, - MaxDamage = 0x00008000, - HitSound = 0x00010000, - MissSound = 0x00020000, - Speed = 0x00040000, - MaxRange = 0x00080000, - Skill = 0x00100000, - Type = 0x00200000, - Animation = 0x00400000, - Resource = 0x00800000, - Attributes = 0x01000000, - WeaponAttributes = 0x02000000, - PlayerConstructed = 0x04000000, - SkillBonuses = 0x08000000, - Slayer2 = 0x10000000, - ElementalDamages = 0x20000000, - EngravedText = 0x40000000 - } } public enum CheckSlayerResult From 9b35b39d0de4eb65973beabc64bb413df8bd8afc Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:18:06 -0700 Subject: [PATCH 42/64] fix: stop stack merges and splits from laundering PlayerConstructed (#2576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `PlayerConstructed` is per-instance provenance, and #2574 put it on every crafted item — including potions, arrows and other stackables. Stack operations were written when no item carried provenance of any kind, so they treated two piles of the same graphic as interchangeable. **Merging** keeps the receiving stack's value. Dropping bought potions onto a crafted stack made the whole pile count as crafted; the reverse order erased it. Which one happened was decided by drag direction alone. **Splitting** rebuilds one half in `Mobile.LiftItemDupe`, which copies a fixed list of fields rather than going through `Dupe`/`CopyProperties`. `PlayerConstructed` was not on that list, so dragging part of a pile off stripped the new half. Worth calling out: `[IgnoreDupe]` does **not** govern this path — it only applies to `Dupe()`. Reasoning "the field isn't `[IgnoreDupe]`, so it copies" is wrong here. ## Changes - `Item.CanStackWith` compares `PlayerConstructed`, so crafted and non-crafted never merge into one indistinguishable pile. - `Mobile.LiftItemDupe` copies `PlayerConstructed` onto the remainder, so a split cannot produce halves that disagree about what they are. Refusing to merge is the whole fix. A stack has nowhere to record provenance, so the only coherent behaviour is to keep the two piles apart rather than pick a winner. ## What this deliberately does not do Paths that genuinely **virtualize** an item — pouring from a `PotionKeg`, for one — rebuild it without the flag, and the result is simply treated as not crafted. That is accepted rather than worked around; the alternative is threading provenance through every count-based container, which buys little. The keg stores a `Held` int rather than a stack, so nothing there depends on merging and nothing breaks. `CommodityDeed` is unaffected — it holds the real `Commodity` item rather than a count, so the flag rides along. ## Player-visible effect Crafted potions and arrows will no longer stack with bought or looted ones. That is the intended invariant, and it is the reason the flag can be trusted at all. ## Tests 7 new tests in `Server.Tests`: both merge directions, the matching-provenance case, split copying, and the split/re-merge round trip. `Server.Tests` **822 passing**, `UOContent.Tests` **701 passing**, build clean with 0 warnings. --- .../Items/PlayerConstructedStackingTests.cs | 136 ++++++++++++++++++ Projects/Server/Items/Item.cs | 1 + Projects/Server/Mobiles/Mobile.cs | 1 + 3 files changed, 138 insertions(+) create mode 100644 Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs new file mode 100644 index 000000000..05595068a --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -0,0 +1,136 @@ +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class PlayerConstructedStackingTests +{ + // PlayerConstructed is per-instance provenance, and stack operations were written when no + // item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one + // half from a fixed list of fields, so a flag that is not accounted for in both places is + // one that ordinary stacking can launder or erase. + + // Stands in for a real stackable type. LiftItemDupe builds the remainder through the + // parameterless constructor and copies only a fixed list of fields onto it -- Stackable is + // not on that list -- so the remainder is only stackable if the type restores it the way + // every genuine stackable does. + private class StackableItem : Item + { + public StackableItem() => Stackable = true; + + public StackableItem(Serial serial) : base(serial) => Stackable = true; + } + + private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => + new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; + + [Fact] + public void CanStackWith_IsFalseWhenProvenanceDiffers() + { + var bought = MakeStack((Serial)0x1, 5, false); + var crafted = MakeStack((Serial)0x2, 5, true); + + try + { + // Both orders must fail. Whichever is the receiver decides the merged pile's flag, + // so allowing either one means the result is decided by drag direction. + Assert.False(bought.CanStackWith(crafted)); + Assert.False(crafted.CanStackWith(bought)); + } + finally + { + bought.Delete(); + crafted.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) + { + var first = MakeStack((Serial)0x1, 5, playerConstructed); + var second = MakeStack((Serial)0x2, 7, playerConstructed); + + try + { + Assert.True(first.CanStackWith(second)); + } + finally + { + first.Delete(); + second.Delete(); + } + } + + [Fact] + public void StackWith_RefusesToMergeAcrossProvenance() + { + var bought = MakeStack((Serial)0x1, 5, false); + var crafted = MakeStack((Serial)0x2, 5, true); + + try + { + Assert.False(bought.StackWith(null, crafted, false)); + Assert.Equal(5, bought.Amount); + Assert.Equal(5, crafted.Amount); + Assert.False(bought.PlayerConstructed); + Assert.False(crafted.Deleted); + } + finally + { + bought.Delete(); + crafted.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed) + { + var stack = MakeStack((Serial)0x1, 10, playerConstructed); + Item remainder = null; + + try + { + remainder = Mobile.LiftItemDupe(stack, 4); + + Assert.NotNull(remainder); + Assert.NotSame(stack, remainder); + Assert.Equal(4, stack.Amount); + Assert.Equal(6, remainder.Amount); + Assert.Equal(playerConstructed, remainder.PlayerConstructed); + } + finally + { + stack.Delete(); + remainder?.Delete(); + } + } + + [Fact] + public void SplitHalvesRemainStackableWithEachOther() + { + // The two halves of a split must still be one pile's worth: if the split dropped the + // flag, the remainder would no longer stack back onto what it came from. + var stack = MakeStack((Serial)0x1, 10, true); + Item remainder = null; + + try + { + remainder = Mobile.LiftItemDupe(stack, 4); + Assert.NotNull(remainder); + + Assert.True(stack.CanStackWith(remainder)); + Assert.True(stack.StackWith(null, remainder, false)); + Assert.Equal(10, stack.Amount); + Assert.True(stack.PlayerConstructed); + } + finally + { + stack.Delete(); + remainder?.Delete(); + } + } +} diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 61e6f644f..889be4619 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -2350,6 +2350,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert dropped.ItemID == ItemID && dropped.Hue == Hue && dropped.Name == Name && + dropped.PlayerConstructed == PlayerConstructed && dropped.Amount + Amount <= 60000 && dropped != this; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index e47f977a7..6755c244b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -5248,6 +5248,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.Name = oldItem.Name; item.Weight = oldItem.Weight; + item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; item.Map = oldItem.Map; From 240118340e56e3440d1e0dd960d851dd56530f2b Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:58:03 -0700 Subject: [PATCH 43/64] fix: stop the idle-sleep backoff tripping on healthy hosts (#2572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The late-wake detector added in #2559 suspends idle sleeping on perfectly healthy hosts. The visible symptom is this Warning firing periodically on stable machines: > This host returned a 2ms idle wait at least 8ms late 2 time(s) in the last second; idle sleeping suspended for 5000ms Demoting it to Debug would hide the symptom but not the cost: every one of those lines means the shard dropped idle sleeping for 5s and burned a full core for no reason. The detector is what was mis-tuned. ## Cause 1 — lateness was a count, not a rate An idle loop performs **~400–500 sleeps per second** (2ms each, bounded by the 8ms wheel tick). The trip condition was `late > 1` across two consecutive one-second samples — a **0.4% tail-outlier rate**. A co-tenant burst, a page fault, or another process changing the system timer resolution clears that bar on a healthy host. A host that genuinely cannot schedule the process — throttled burstable vCPU — returns *most* of its waits late. Signal and noise were two orders of magnitude apart, and the check sat in the noise. Now gated on the proportion, with the absolute count kept as a floor: ```csharp if (late <= _lateWakeThreshold) { _consecutiveBadSamples = 0; return; } // floor if (late * 100 < sleeps * _lateWakePercent) { _consecutiveBadSamples = 0; return; } // rate ``` New `server.lateWakePercent` (default `10`). The floor is what keeps a window with only a handful of sleeps from tripping on a meaningless percentage; `server.lateWakeThreshold` keeps its existing meaning. ## Cause 2 — GC pauses were charged to the host `dev-docs/debugging-event-loop.md` already documents that the GC collects preferentially **during idle sleeps** — that is the natural pause point it looks for. So the detector was systematically measuring the GC's chosen pause point and billing it to the host's scheduler. Not an occasional coincidence; a designed-in one. ```csharp var collections = GC.CollectionCount(1); NetState.WaitForCompletion(requested); ... if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections) ``` Gen1 (which counts gen2 with it) rather than gen0 — gen0 pauses don't approach the 8ms `TickRate` bar anyway, and gating on them would discard useful samples. The second read short-circuits behind the overshoot test, so the common path costs **one** `GC.CollectionCount` per sleep: an internal counter read, single-digit nanoseconds, ~500/sec. ## Cause 3 — every backoff logged at Warning Tiered to the escalation that already existed, since a single suspension is recoverable and not something an operator can act on: | Backoff | Level | |---|---| | 1–2 | `Debug` | | 3–5 | `Warning` (now includes the sleep count and "for the Nth time running") | | ceiling | `Error`, unchanged | | recovery | `Information` (new) | Each backoff doubles the suspension, so every line is already a distinct escalation step — no further rate limiting needed. ## Drive-by The `BackoffResetAfterCleanMs` reset only ran on the path to a *new* backoff, making it unreachable for a host that recovered for good — such a host never cleared its escalation or re-armed `_loggedBackoffCeiling`. It now runs on every health sample, which is also what makes the new recovery line reachable. ## Testing Full solution builds clean, 0 warnings. No tests added: the state is private static in `Core` coupled to `_tickCount` with no injection point, and nothing covered it before — adding a seam purely to test it seemed worse than the gap. Happy to add one if reviewers disagree. --- Projects/Server/Main.cs | 157 +++++++++++++++++++++---------- dev-docs/debugging-event-loop.md | 9 +- dev-docs/server-requirements.md | 3 +- 3 files changed, 115 insertions(+), 54 deletions(-) diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 620d67c28..608256651 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -39,21 +39,19 @@ public static class Core { private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); - // Written from other threads (Kill, RequestSnapshot) and read by the event loop. Volatile - // because the loop now genuinely blocks between reads rather than spinning past them. + // Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads. private static volatile bool _performProcessKill; private static bool _restartOnKill; private static volatile bool _performSnapshot; private static string _snapshotPath; - // A backstop, not a latency control: the wheel's tick rate bounds the sleep, so this only - // limits the damage if a wake signal is ever missed. Measured across 1/2/4/8ms, 2 is optimal. + // A backstop, not a latency control: the wheel's tick rate already bounds the sleep. + // Measured across 1/2/4/8ms; 2 is optimal. private static int _eventLoopIdleWaitMs = 2; /// - /// Longest the loop will block while idle, in milliseconds. 0 disables idle sleeping, - /// leaving the loop to spin; the adaptive backoff does the same thing temporarily when the - /// host keeps returning waits late. + /// Longest the loop will block while idle, in milliseconds. 0 spins instead; the backoff + /// does the same temporarily when the host keeps returning waits late. /// public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs; @@ -74,9 +72,8 @@ public static class Core private const long HealthSampleIntervalMs = 1000; - // Backoff escalates by doubling: a fixed suspension oscillates forever on a persistently bad - // host, while doubling converges on "stop sleeping" within minutes yet still recovers from a - // transient problem. + // Doubling: a fixed suspension oscillates forever on a persistently bad host, while doubling + // converges on "stop sleeping" yet still recovers from a transient. private const long BackoffBaseMs = 5000; private const long BackoffMaxMs = 120_000; private const int BackoffMaxShift = 5; @@ -84,16 +81,20 @@ public static class Core // Clean streak that clears the escalation. private const long BackoffResetAfterCleanMs = 60_000; - // A sleep is bounded by the time to the next wheel turn, so a correctly honoured sleep can - // never miss a deadline; the only way sleeping harms the wheel is the wait returning late - // (the host descheduled the process). That overshoot is measured per sleep, which is why - // server work -- saves, heavy commands, deep timer callbacks -- cannot trip this backoff. - // Loop-thread only, so plain increments are safe. + // Below this a backoff is still recoverable and not actionable, so it only logs at Debug. + private const int WarnAfterConsecutiveBackoffs = 3; + + // A sleep is bounded by the next wheel turn, so only a wait returning late can cost a deadline. + // Measured per sleep, which is why server work (saves, heavy commands) cannot trip the backoff. private static int _lateWakes; + // Denominator for the late-wake rate. + private static int _sleepAttempts; + private static long _nextHealthSample; private static long _idleSleepSuspendedUntil; private static int _lateWakeThreshold = 1; + private static int _lateWakePercent = 10; private static long _idleSleepBackoffs; private static int _consecutiveBadSamples; private static int _consecutiveBackoffs; @@ -115,7 +116,25 @@ public static class Core _nextHealthSample = _tickCount + HealthSampleIntervalMs; var late = _lateWakes; + var sleeps = _sleepAttempts; _lateWakes = 0; + _sleepAttempts = 0; + + // A clean streak resets the escalation and re-arms the ceiling Error. Gated on the count + // rather than a "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. + if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) + { + if (_consecutiveBackoffs >= WarnAfterConsecutiveBackoffs) + { + logger.Information( + "This host has returned idle waits on time for {Duration}ms; idle sleeping is back to normal", + BackoffResetAfterCleanMs + ); + } + + _consecutiveBackoffs = 0; + _loggedBackoffCeiling = false; + } if (late <= _lateWakeThreshold) { @@ -123,8 +142,17 @@ public static class Core return; } - // Require the condition to persist: any host can drop one sample to unrelated load, and a - // host that is genuinely oversubscribed stays that way, so it trips on the second sample. + // Lateness is a rate: an idle loop sleeps hundreds of times a second, so a few outliers are + // normal, while a host that cannot schedule the process returns most of its waits late. The + // threshold above is the floor for windows with too few sleeps for a proportion to mean anything. + if (late * 100 < sleeps * _lateWakePercent) + { + _consecutiveBadSamples = 0; + return; + } + + // Require persistence: any host can drop one sample to unrelated load, but an oversubscribed + // one stays bad. if (++_consecutiveBadSamples < 2) { return; @@ -135,15 +163,6 @@ public static class Core return; } - // A long clean streak resets the escalation, re-arming the ceiling Error so a host that - // recovers and later degrades again gets re-reported. Gated on the count rather than a - // "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. - if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) - { - _consecutiveBackoffs = 0; - _loggedBackoffCeiling = false; - } - _currentBackoffMs = Math.Min(BackoffBaseMs << Math.Min(_consecutiveBackoffs, BackoffMaxShift), BackoffMaxMs); _consecutiveBackoffs++; _lastBackoffAt = _tickCount; @@ -152,7 +171,7 @@ public static class Core if (_currentBackoffMs >= BackoffMaxMs) { - // Escalation has run out of room; say so once in terms the operator can act on. + // Escalation has run out of room; say so once. if (!_loggedBackoffCeiling) { _loggedBackoffCeiling = true; @@ -167,12 +186,31 @@ public static class Core return; } + // Each backoff doubles the suspension, so every line is a distinct escalation step and + // needs no further rate limiting. + if (_consecutiveBackoffs < WarnAfterConsecutiveBackoffs) + { + logger.Debug( + "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) " + + "in the last second; idle sleeping suspended for {Duration}ms", + _eventLoopIdleWaitMs, + Timer.TickRate, + late, + sleeps, + _currentBackoffMs + ); + + return; + } + logger.Warning( - "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} time(s) in the last " + - "second; idle sleeping suspended for {Duration}ms", + "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) in " + + "the last second, for the {Backoffs}th time running; idle sleeping suspended for {Duration}ms", _eventLoopIdleWaitMs, Timer.TickRate, late, + sleeps, + _consecutiveBackoffs, _currentBackoffMs ); } @@ -361,8 +399,8 @@ public static class Core _restartOnKill = restart; _performProcessKill = true; - // Callers are usually off-loop (console input, signal handlers). Without this the loop - // would not notice the request until it woke for some other reason. + // Callers are usually off-loop (console input, signal handlers); wake so the request + // is noticed now rather than whenever the loop next surfaces. NetState.Wake(); } @@ -565,8 +603,8 @@ public static class Core _eventLoopIdleWaitMs = Math.Max(0, idleWaitMs); - // 16ms-budget misses per second before idle sleeping backs off. Raise to tolerate a - // jittery host; set very high to disable the backoff. + // Floor for the backoff: idle waits per second the host may return a full tick late before + // the rate test below applies at all. Set very high to disable the backoff. var lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); if (lateWakeThreshold < 0) { @@ -578,6 +616,20 @@ public static class Core _lateWakeThreshold = Math.Max(0, lateWakeThreshold); + // Share of a second's idle waits that must return late before the backoff trips. 0 leaves + // the threshold above in sole charge. + var lateWakePercent = ServerConfiguration.GetSetting("server.lateWakePercent", 10); + if (lateWakePercent is < 0 or > 100) + { + logger.Warning( + "server.lateWakePercent {Value} is outside 0-100; using {Clamped}", + lateWakePercent, + Math.Clamp(lateWakePercent, 0, 100) + ); + } + + _lateWakePercent = Math.Clamp(lateWakePercent, 0, 100); + var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); // Load UOContent.dll @@ -594,10 +646,8 @@ public static class Core AssemblyHandler.LoadAssemblies(assemblyFiles); - // First-boot interactive setup. Runs after assemblies are loaded (so content can - // register prompts) but before any Serilog output, so console prompts are not - // interleaved with the async console sink. Handlers self-gate on first-boot state - // (e.g. "is my setting already present?"). + // First-boot interactive setup. After assemblies load so content can register prompts, + // before any Serilog output so prompts are not interleaved with the async console sink. AssemblyHandler.Invoke("ConfigurePrompts"); logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); @@ -607,9 +657,8 @@ public static class Core _now = DateTime.UtcNow; _firstTick = _tickCount = GetTimestamp(); - // Seed schedule state from the first real tick: tick counts are not guaranteed to start - // anywhere near zero (hypervisor pass-through counters), so zero-initialized deadlines - // would compare wrong. See dev-docs/tick-counts.md. + // Seed from a real tick: tick counts need not start near zero, so a zero-initialized + // deadline compares wrong. See dev-docs/tick-counts.md. _nextHealthSample = _tickCount + HealthSampleIntervalMs; _idleSleepSuspendedUntil = _tickCount; @@ -630,9 +679,8 @@ public static class Core PingServer.Start(); EventSink.InvokeServerStarted(); - // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop would - // quietly run a tick behind; spinning is the lesser evil and must not be silent. Only - // fires when both the ring's high-res timer and its timeBeginPeriod fallback failed. + // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop runs a + // tick behind. Only fires when the high-res timer and the timeBeginPeriod fallback both failed. if (_eventLoopIdleWaitMs > 0 && NetState.Ring?.SupportsHighResolutionWait == false) { logger.Error( @@ -649,8 +697,7 @@ public static class Core /// /// True when every queue the loop drains is empty, so sleeping cannot strand pending work. - /// The drains are bounded (ProcessDeltaQueue stops at the count seen on entry, ExecuteTasks - /// at its per-frame cap), so leftovers are normal and must keep the loop awake. + /// The drains are bounded, so leftovers are normal and must keep the loop awake. /// private static bool IsIdle() => !Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle; @@ -709,21 +756,28 @@ public static class Core if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle()) { - // Re-read the clock: the loop body consumed real time, and a stale timestamp - // would overstate the time to the next tick and sleep straight past it. + // Re-read the clock: a stale timestamp overstates the time to the next tick + // and sleeps straight past it. var start = GetTimestamp(); var due = Timer.MillisecondsUntilNextTick(start); if (due > 0) { var requested = (int)Math.Min(due, _eventLoopIdleWaitMs); + + // The GC prefers to collect during idle sleeps, so its pauses land here by + // design and are not the host's fault. Gen1 and above (what + // CollectionCount(1) counts) are the only pauses long enough to reach a tick. + var collections = GC.CollectionCount(1); + NetState.WaitForCompletion(requested); var elapsed = GetTimestamp() - start; EventLoopProfiler.SleepEnd(requested, elapsed); + _sleepAttempts++; - // A sleep is bounded by the next wheel turn, so only a wait the host - // returned late can cost the wheel a deadline. - if (elapsed - requested >= Timer.TickRate) + // The second collection read sits behind the overshoot test, so the common + // path reads the counter once, not twice. + if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections) { _lateWakes++; } @@ -745,8 +799,7 @@ public static class Core _snapshotPath = snapshotPath; _performSnapshot = true; - // Save requests arrive off-loop. Wake so the snapshot starts now rather than after the - // loop happens to surface for another reason. + // Save requests arrive off-loop; wake so the snapshot starts now. NetState.Wake(); } diff --git a/dev-docs/debugging-event-loop.md b/dev-docs/debugging-event-loop.md index 8b5fdcf36..c38a0e6f7 100644 --- a/dev-docs/debugging-event-loop.md +++ b/dev-docs/debugging-event-loop.md @@ -27,9 +27,16 @@ No build changes needed. Three signals exist, all actionable: | Signal | Meaning | Action | |---|---|---| | Startup error: *host cannot honour short waits* | No high-resolution timer and `timeBeginPeriod` failed. Very old or unusual Windows. | Nothing is wrong with the server; it spins and uses a full core. Upgrade the OS or accept the core. | -| Warning: *host returned a Nms idle wait late* + sleeping suspended | The OS did not reschedule the process promptly after a 1–2ms wait. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | +| Warning: *host returned a Nms idle wait late … for the Nth time running* | The OS did not reschedule the process promptly after a 1–2ms wait, through several escalating backoffs. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | +| Error: *keeps returning idle waits late and sleeping has backed off N times* | The escalation hit its 120s ceiling. The host is not going to recover. | As above, but stop waiting for it to settle. Logged once per degradation, re-armed after a clean minute. | | Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` / `Spinning - host cannot honor short waits` | Same as above; the last verdict is the startup error's state, not a config choice. | +The first two backoffs of any episode log at **Debug**, not Warning: a single suspension is +recoverable and not something an operator can act on. Raise the log level if you are chasing a +marginal host and want to see them. Late wakes that coincide with a gen1-or-higher GC are not +counted at all — the GC deliberately collects during idle sleeps, so its pauses land there by +design and are not the host's fault. + If none of these fired and the shard still feels laggy, the cause is work, GC, or something a boot-time signal cannot see. Continue. diff --git a/dev-docs/server-requirements.md b/dev-docs/server-requirements.md index daf71e03e..0b2c50569 100644 --- a/dev-docs/server-requirements.md +++ b/dev-docs/server-requirements.md @@ -105,7 +105,8 @@ See the README for the full supported list. Two things are worth calling out: | Setting | Default | Why change it | |---|---|---| | `server.eventLoopIdleWaitMs` | `2` | `0` never sleeps: ~98% of one core, but zero skipped timer slots and zero lag. The choice for a large shard on dedicated CPU that would rather spend a core than risk a late wake. Above `2` the wheel starts losing slots. | -| `server.lateWakeThreshold` | `1` | Idle waits the host may return a full tick late, per second, before idle sleeping backs off. Raise on a jittery host; set very high to disable the backoff. | +| `server.lateWakeThreshold` | `1` | Floor for the backoff: idle waits the host may return a full tick late, per second, before the rate test below applies at all. Raise on a jittery host; set very high to disable the backoff. | +| `server.lateWakePercent` | `10` | Share of a second's idle waits that must come back late before idle sleeping backs off. An idle loop sleeps hundreds of times a second, so a bare count cannot tell a few tail outliers from a host that never schedules the process — a genuinely bad host misses *most* of its waits. `0` leaves `lateWakeThreshold` in sole charge. | | `world.useMultithreadedSaves` | `true` | Set `false` on 2-core hosts so saves do not contend with the game loop. | | `pathfinding.prebakeMaps` | varies | Leave off on memory-constrained hosts; it peaks above 1 GB while baking. | | `network.sendBufferSize` | 256 KB | Lower it if you are memory-bound with many connections. | From 2dbaa873778900fb666f927064ffb6160767f6ec Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:22:35 -0700 Subject: [PATCH 44/64] feat: make the blocklist and manual allowlist opt-in; cut the ban subsystem's on-loop cost (#2577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 && ` 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` 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. --- .../Network/AutoDenylist/AutoDenylistTests.cs | 77 ++++++- .../Tests/Network/BanExemptionsTests.cs | 28 +-- .../Blocklist/BlocklistConfigurationTests.cs | 10 + .../ManualAllowlistConfigurationTests.cs | 66 ++++++ .../Network/AutoDenylist/AutoDenylist.cs | 215 ++++++++++++++---- .../AutoDenylist/AutoDenylistConfiguration.cs | 15 +- Projects/UOContent/Network/BanExemptions.cs | 4 +- .../Blocklist/BlocklistConfiguration.cs | 31 +-- .../Network/Blocklist/BlocklistFilter.cs | 74 ++++-- .../Network/Blocklist/BlocklistSnapshot.cs | 5 +- .../Network/CrowdSec/CrowdSecConfiguration.cs | 5 +- .../Network/CrowdSec/CrowdSecReporter.cs | 10 +- .../UOContent/Network/Firewall/Firewall.cs | 10 +- .../Network/LoginAllowlist/LoginAllowlist.cs | 134 ++++++++--- .../LoginAllowlistConfiguration.cs | 12 +- .../ManualAllowlist.cs} | 55 +++-- .../ManualAllowlistConfiguration.cs | 83 +++++++ dev-docs/ip-bans-and-allowlists.md | 44 +++- dev-docs/networking-packets.md | 6 +- 19 files changed, 696 insertions(+), 188 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs rename Projects/UOContent/Network/{Blocklist/FileAllowlist.cs => ManualAllowlist/ManualAllowlist.cs} (83%) create mode 100644 Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs diff --git a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs index 3931eb3e4..ae926794d 100644 --- a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs @@ -20,8 +20,8 @@ using Xunit; namespace Server.Tests.Network.AutoDenylists; -// Static store, so every test resets it first. Addresses come from TEST-NET-2 (198.51.100.0/24). -// Sequential: the cap tests reach Sweep, which rents from STArrayPool, which is not thread-safe. +// Static store, so every test resets it first and none may run alongside another. +// Addresses come from TEST-NET-2 (198.51.100.0/24). [Collection("Sequential UOContent Tests")] public class AutoDenylistTests { @@ -62,8 +62,11 @@ public class AutoDenylistTests Assert.Equal(0, AutoDenylist.Count); } + // Not refreshed on purpose: it is what keeps insertion order equal to expiry order, so retiring lapsed + // entries costs the number expiring instead of the number held. A flooder whose hold lapses trips the + // rate limiter on its next attempt -- which runs ahead of the connection filters -- and is held again. [Fact] - public void Repeat_detection_extends_the_hold() + public void Repeat_detection_does_not_extend_the_hold() { Reset(); var ip = IPAddress.Parse("198.51.100.13"); @@ -71,8 +74,72 @@ public class AutoDenylistTests AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now); AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1); - Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // would have lapsed without the second - Assert.Equal(1, AutoDenylist.Count); // and did not add a duplicate + Assert.Equal(1, AutoDenylist.Count); // no duplicate + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); + Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // lapses from the FIRST detection + } + + // The ring carries the expiry and the set carries membership; if they ever disagree, an address is + // either denied forever or retired early. + [Fact] + public void Ring_and_set_stay_in_step() + { + Reset(maxEntries: 4); + + for (var i = 0; i < 8; i++) + { + AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{70 + i}"), BanReasons.InvalidSeed, Now); + } + + Assert.Equal(4, AutoDenylist.Count); + Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); + + AutoDenylist.Release(IPAddress.Parse("198.51.100.71")); + Assert.Equal(3, AutoDenylist.Count); + Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); + + AutoDenylist.Drain(Now + DurationMs + 1); + Assert.Equal(0, AutoDenylist.Count); + Assert.Equal(0, AutoDenylist.RingCount); + } + + // The ring grows in doublings but is capped at maxEntries, which is not a power of two. Filling exactly + // to it must land on the last slot rather than off the end. + [Fact] + public void Ring_fills_exactly_to_a_non_power_of_two_cap() + { + Reset(maxEntries: 100); + + for (var i = 0; i < 120; i++) + { + AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{i}"), BanReasons.InvalidSeed, Now); + } + + Assert.Equal(100, AutoDenylist.Count); + Assert.Equal(100, AutoDenylist.RingCount); + + // And the whole ring still drains, so no slot was stranded by a wrapped write. + AutoDenylist.Drain(Now + DurationMs + 1); + Assert.Equal(0, AutoDenylist.Count); + Assert.Equal(0, AutoDenylist.RingCount); + } + + // Releasing leaves no ring record behind, so a re-detection is not retired by the old one. + [Fact] + public void Release_then_re_hold_is_not_retired_by_the_stale_record() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.15"); + + AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); + AutoDenylist.Release(ip); + + var later = Now + DurationMs - 1; + AutoDenylist.Hold(ip, BanReasons.RateLimit, later); + + // The first hold's expiry has passed; the second must survive it. + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); + Assert.Equal(1, AutoDenylist.RingCount); } [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs index 7e930fbdf..b38364373 100644 --- a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -28,15 +28,15 @@ public class BanExemptionsTests private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); - private static void WithFileAllowlist(string contents) => - FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); + private static void WithManualAllowlist(string contents) => + ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); - private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty); + private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty); [Fact] - public void File_allowlist_exempts_behavioral_contributions() + public void Manual_allowlist_exempts_behavioral_contributions() { - WithFileAllowlist("192.0.2.10"); + WithManualAllowlist("192.0.2.10"); // Subtracting from the blocklist does nothing for behavioural detections, which never consult it. Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled)); @@ -46,10 +46,10 @@ public class BanExemptionsTests } [Fact] - public void File_allowlist_covers_cidr_entries() + public void Manual_allowlist_covers_cidr_entries() { // Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here. - WithFileAllowlist("192.0.2.0/24"); + WithManualAllowlist("192.0.2.0/24"); Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); @@ -57,9 +57,9 @@ public class BanExemptionsTests } [Fact] - public void Manual_bans_are_never_exempt_even_when_file_allowlisted() + public void Manual_bans_are_never_exempt_even_when_allowlisted() { - WithFileAllowlist("192.0.2.10"); + WithManualAllowlist("192.0.2.10"); // An explicit decision outranks the operator's own carve-out, and must not cost a strike. Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); @@ -68,16 +68,16 @@ public class BanExemptionsTests [Fact] public void Unopted_reasons_are_never_exempt() { - WithFileAllowlist("192.0.2.10"); + WithManualAllowlist("192.0.2.10"); Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); } [Fact] - public void File_allowlist_does_not_spend_the_earned_lists_strikes() + public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() { - WithFileAllowlist("192.0.2.10"); + WithManualAllowlist("192.0.2.10"); // Unconditional, so the revocable list must not be consulted -- that would burn a strike. Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); @@ -86,7 +86,7 @@ public class BanExemptionsTests [Fact] public void Falls_through_to_the_login_allowlist_when_not_file_listed() { - WithEmptyFileAllowlist(); + WithEmptyManualAllowlist(); var consulted = 0; @@ -107,7 +107,7 @@ public class BanExemptionsTests [Fact] public void Null_address_is_never_exempt() { - WithEmptyFileAllowlist(); + WithEmptyManualAllowlist(); Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); } diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs index 6464a1bed..3d81c8d87 100644 --- a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -30,6 +30,7 @@ public class BlocklistConfigurationTests { var original = new BlocklistSettings { + Enabled = true, File = "D:/shared/ip-blocklist.txt", ReloadInterval = TimeSpan.FromMinutes(5), ReportHits = false, @@ -39,6 +40,7 @@ public class BlocklistConfigurationTests var json = JsonConfig.Serialize(original); + Assert.Contains("\"enabled\"", json); Assert.Contains("\"file\"", json); Assert.Contains("\"reloadInterval\"", json); Assert.Contains("\"reportHits\"", json); @@ -48,6 +50,7 @@ public class BlocklistConfigurationTests var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); Assert.NotNull(restored); + Assert.Equal(original.Enabled, restored.Enabled); Assert.Equal(original.File, restored.File); Assert.Equal(original.ReloadInterval, restored.ReloadInterval); Assert.Equal(original.ReportHits, restored.ReportHits); @@ -55,6 +58,13 @@ public class BlocklistConfigurationTests Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); } + // The point of the flag: a shard that never opts in must not start the reload poll. + [Fact] + public void Blocklist_is_off_by_default() + { + Assert.False(new BlocklistSettings().Enabled); + } + // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves // without the other, a shard silently enforces nothing. [Fact] diff --git a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs new file mode 100644 index 000000000..1146414b5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs @@ -0,0 +1,66 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ManualAllowlistConfigurationTests.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.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.ManualAllowlists; + +public class ManualAllowlistConfigurationTests +{ + // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings + // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. + [Fact] + public void ManualAllowlistSettings_RoundTripsThroughJsonConfig() + { + var original = new ManualAllowlistSettings + { + Enabled = true, + Files = ["D:/shared/ip-allowlist*.txt"], + ReloadInterval = TimeSpan.FromMinutes(5) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"enabled\"", json); + Assert.Contains("\"files\"", json); + Assert.Contains("\"reloadInterval\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.Enabled, restored.Enabled); + Assert.Equal(original.Files, restored.Files); + Assert.Equal(original.ReloadInterval, restored.ReloadInterval); + } + + // The point of the flag: a shard that never opts in must not start the reload poll. + [Fact] + public void Manual_allowlist_is_off_by_default() + { + Assert.False(new ManualAllowlistSettings().Enabled); + } + + // The generator creates ip-allowlist.txt beside the blocklist; the wildcard is what picks up a + // carve-out file (-RefreshCarveouts writes ip-allowlist-starlink.txt) with no config edit. + [Fact] + public void Default_pattern_matches_the_generator_output_path() + { + Assert.Equal(["Configuration/ip-allowlist*.txt"], new ManualAllowlistSettings().Files); + } +} diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs index b9b4dd7c1..7b3c44ae4 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -32,12 +32,26 @@ namespace Server.Network; /// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban /// without a ban's review. Only verdicts are held. /// +/// +/// A hold is never refreshed, so every expiry is insertion + duration and the ring is sorted by +/// construction. Retiring lapsed entries is therefore the number expiring rather than the number held, which +/// is what lets the cap be sized for the flood instead of for a scan. +/// public static class AutoDenylist { private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist)); - // Address (normalized v6 bits) -> Core.TickCount at which the hold lapses. Loop-only. - private static readonly Dictionary _held = []; + // Membership only (normalized v6 bits). Loop-only. The expiry lives beside the key in the ring, so + // there is exactly one copy of it and the two cannot disagree. + private static readonly HashSet _held = []; + + // The same keys in expiry order. Parallel arrays rather than an array of structs: UInt128 forces + // 16-byte alignment, so a packed (key, expiry) struct costs 32 bytes where these cost 24 -- and the + // drain reads only the long[], 8 sequential bytes per entry. + private static UInt128[] _ringKeys = []; + private static long[] _ringExpiry = []; + private static int _ringHead; + private static int _ringCount; private static bool _enabled; private static long _durationMs; @@ -46,6 +60,9 @@ public static class AutoDenylist public static int Count => _held.Count; + // Test seam: the ring and the set hold the same entries, and nothing else may assume it. + internal static int RingCount => _ringCount; + public static void Configure() { AutoDenylistConfiguration.Load(); @@ -77,35 +94,47 @@ public static class AutoDenylist return false; } + Drain(nowTicks); + var key = address.ToUInt128(); - // An address already held is just extended, so no cap check is needed. - if (!_held.ContainsKey(key) && _held.Count >= _maxEntries) + // Deliberately not refreshed: the first detection sets the expiry and later ones leave it alone. + // That keeps insertion order equal to expiry order, which is why the drain can stop at the first + // live record. A flooder whose hold lapses trips the rate limiter on its next attempt -- which + // runs ahead of the connection filters -- and is held again. + if (!_held.Add(key)) { - Sweep(nowTicks); - - if (_held.Count >= _maxEntries) - { - if (!_warnedFull) - { - _warnedFull = true; - logger.Warning( - "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", - _maxEntries - ); - } - - return false; - } + return true; } - _held[key] = nowTicks + _durationMs; + // Drain already reclaimed everything reclaimable, so being over now means genuinely full. + if (_held.Count > _maxEntries) + { + _held.Remove(key); + + if (!_warnedFull) + { + _warnedFull = true; + logger.Warning( + "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", + _maxEntries + ); + } + + return false; + } + + Push(key, nowTicks + _durationMs); return true; } public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount); - /// The pure decision, split out so the accept-path policy can be tested without a clock. + /// + /// The accept-path decision, split out so the policy can be tested without a clock. Drains first: the + /// expiry lives in the ring, not beside the membership, so a lapsed hold has to be retired here rather + /// than expired on read. One array read when nothing has lapsed. + /// internal static bool IsDenied(IPAddress address, long nowTicks) { if (!_enabled || address == null) @@ -113,46 +142,136 @@ public static class AutoDenylist return false; } - // Decided on read, so a lapsed hold cannot deny even before the sweep. Subtraction: TickCount wraps. - return _held.TryGetValue(address.ToUInt128(), out var expires) && expires - nowTicks > 0; + Drain(nowTicks); + return _held.Contains(address.ToUInt128()); } /// Releases an address early, e.g. when an operator retracts a ban. public static void Release(IPAddress address) { - if (_enabled && address != null) - { - _held.Remove(address.ToUInt128()); - } - } - - internal static void Sweep(long nowTicks) - { - if (_held.Count == 0) + if (!_enabled || address == null) { return; } - var lapsed = 0; - - foreach (var (address, expires) in _held) + var key = address.ToUInt128(); + if (_held.Remove(key)) { - if (expires - nowTicks <= 0) - { - _held.Remove(address); - lapsed++; - } + // The ring record has to go too. Nothing records that this key was released, so if it were + // detected again before the old record lapsed, that record would retire the new hold early. + // O(n), but this is an operator retraction, not the accept path. + PurgeRing(key); + } + } + + /// + /// Retires everything that has lapsed. Expiries only ever increase along the ring, so the first live + /// record ends the scan and the cost is the number actually expiring, not the number held. + /// + internal static void Drain(long nowTicks) + { + var before = _ringCount; + + // Subtraction, never a direct compare: tick counts wrap. See dev-docs/tick-counts.md. + while (_ringCount > 0 && _ringExpiry[_ringHead] - nowTicks <= 0) + { + _held.Remove(_ringKeys[_ringHead]); + _ringHead = _ringHead + 1 == _ringKeys.Length ? 0 : _ringHead + 1; + _ringCount--; } - if (lapsed > 0) + if (_ringCount != before) { _warnedFull = false; } } + private static void Push(UInt128 key, long expiry) + { + if (_ringCount == _ringKeys.Length) + { + Grow(); + } + + var tail = _ringHead + _ringCount; + if (tail >= _ringKeys.Length) + { + tail -= _ringKeys.Length; + } + + _ringKeys[tail] = key; + _ringExpiry[tail] = expiry; + _ringCount++; + } + + private static void Grow() + { + // Capped at the entry cap: Push only runs below it, so the ring never needs more, and doubling + // past it would reserve roughly twice the slots it can ever use. + var size = Math.Min(Math.Max(64, _ringKeys.Length * 2), _maxEntries); + var keys = new UInt128[size]; + var expiry = new long[size]; + + for (var i = 0; i < _ringCount; i++) + { + var from = _ringHead + i; + if (from >= _ringKeys.Length) + { + from -= _ringKeys.Length; + } + + keys[i] = _ringKeys[from]; + expiry[i] = _ringExpiry[from]; + } + + _ringKeys = keys; + _ringExpiry = expiry; + _ringHead = 0; + } + + private static void PurgeRing(UInt128 key) + { + var capacity = _ringKeys.Length; + + for (var i = 0; i < _ringCount; i++) + { + var at = _ringHead + i; + if (at >= capacity) + { + at -= capacity; + } + + if (_ringKeys[at] != key) + { + continue; + } + + // Close the gap so the ring stays contiguous and expiry-ordered. + for (var j = i; j < _ringCount - 1; j++) + { + var to = _ringHead + j; + if (to >= capacity) + { + to -= capacity; + } + + var from = to + 1 == capacity ? 0 : to + 1; + _ringKeys[to] = _ringKeys[from]; + _ringExpiry[to] = _ringExpiry[from]; + } + + _ringCount--; + return; + } + } + internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries) { _held.Clear(); + _ringKeys = []; + _ringExpiry = []; + _ringHead = 0; + _ringCount = 0; _enabled = enabled; _durationMs = durationMs; _maxEntries = maxEntries; @@ -163,6 +282,8 @@ public static class AutoDenylist /// Accept-path gate for . public sealed class AutoDenylistFilter : IConnectionFilter { + private Timer _sweepTimer; + public string Name => "auto-denylist"; public void Register() @@ -171,12 +292,20 @@ public sealed class AutoDenylistFilter : IConnectionFilter public void Start(CancellationToken token) { - // Only an optimisation: IsDenied expires on read. - Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), () => AutoDenylist.Sweep(Core.TickCount)); + // Only reclaims memory: Hold and IsDenied both drain, so this matters on a shard that has gone + // quiet after a flood and would otherwise hold the ring until someone next connects. + _sweepTimer = Timer.DelayCall( + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(1), + () => AutoDenylist.Drain(Core.TickCount) + ); } public void Stop() { + // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. + _sweepTimer?.Stop(); + _sweepTimer = null; } public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address); diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs index 4d45da350..fd13ef003 100644 --- a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs @@ -21,9 +21,8 @@ using Server.Json; namespace Server.Network; /// -/// Loads the from Configuration/auto-denylist.json (matching the -/// per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file writes -/// a template so operators have something to edit. +/// Loads the from Configuration/auto-denylist.json. Loaded once; +/// a missing file writes a template so operators have something to edit. /// public static class AutoDenylistConfiguration { @@ -76,6 +75,14 @@ public record AutoDenylistSettings /// stops it becoming the exhaustion it prevents. At the cap new addresses are not tracked, but are still /// disconnected by whichever gate detected them. /// + /// + /// A HashSet capacity, not a round number. Grown from empty it steps 36,353 → 75,431 → 156,437 + /// → 324,449, so this fills one exactly instead of stranding slots: 65,536 sat just past a resize and + /// left 9,895 of them unusable. Sized to cover the 50k–250k distinct-source floods seen in practice, + /// for ~19 MB — 36 bytes a set slot plus 24 for the ring record. Raising it is bounded by memory + /// rather than by a scan, since holds are retired from the ring in expiry order; a flood past it wants + /// upstream scrubbing rather than a larger cap. + /// [JsonPropertyName("maxEntries")] - public int MaxEntries { get; set; } = 65536; + public int MaxEntries { get; set; } = 324_449; } diff --git a/Projects/UOContent/Network/BanExemptions.cs b/Projects/UOContent/Network/BanExemptions.cs index 5566bd5e7..c5f6b726a 100644 --- a/Projects/UOContent/Network/BanExemptions.cs +++ b/Projects/UOContent/Network/BanExemptions.cs @@ -20,7 +20,7 @@ using Server.Network.Bans; namespace Server.Network; /// -/// Combines and into the one answer +/// Combines and into the one answer /// asks for, so neither source has to know about the other. /// public static class BanExemptions @@ -51,7 +51,7 @@ public static class BanExemptions } // Deliberate and unconditional, so it wins and must not spend the earned list's strikes. - if (FileAllowlist.Contains(address)) + if (ManualAllowlist.Contains(address)) { return true; } diff --git a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs index 8a2df5ca6..19541636f 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs @@ -21,9 +21,8 @@ using Server.Json; namespace Server.Network.Bans; /// -/// Loads the from Configuration/blocklist.json (matching the -/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file -/// writes a template so operators have something to edit. +/// Loads the from Configuration/blocklist.json. Loaded once; a +/// missing file writes a template so operators have something to edit. /// public static class BlocklistConfiguration { @@ -53,12 +52,19 @@ public static class BlocklistConfiguration } /// -/// Bound configuration for . The filter is inert unless -/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs -/// the generator. +/// Bound configuration for . The filter is inert unless +/// is set and points at a list that exists, so a shard that never runs the generator +/// pays nothing for the defaults. /// public record BlocklistSettings { + /// + /// Whether the accept-path gate runs at all. Off by default: the reload poll runs for the whole + /// uptime, which no shard should pay before an operator has chosen to run a blocklist. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + /// /// Path to the blocklist. A relative path resolves against ; an /// absolute path is used as-is (handy when several shards share one generated list). Set to @@ -67,19 +73,6 @@ public record BlocklistSettings [JsonPropertyName("file")] public string File { get; set; } = "Configuration/ip-blocklist.txt"; - /// - /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same - /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an - /// entry also suppresses ban contributions, which the generator alone cannot do. See - /// . - /// - /// - /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds - /// without anyone editing this file. - /// - [JsonPropertyName("allowlistFiles")] - public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.txt"]; - /// How often the file is checked for changes. Reloads only happen when it actually changed. [JsonPropertyName("reloadInterval")] public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs index 36354160b..030997803 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs @@ -25,8 +25,8 @@ namespace Server.Network.Bans; /// /// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator /// (tools/Export-IpBlocklist.ps1) writes on a schedule. Holds an immutable snapshot swapped -/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is -/// configured or present. +/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Opt-in via +/// blocklist.json's enabled; inert when off, or when no file is configured or present. /// /// /// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on @@ -38,12 +38,13 @@ public sealed class BlocklistFilter : IConnectionFilter { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter)); - // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile - // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. + // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference + // swap is the whole synchronization story: readers see the old or the new snapshot, whole. private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; private readonly PromotedGuard _guard = new(); + private bool _enabled; private string _path; private TimeSpan _interval; private bool _reportHits; @@ -52,6 +53,7 @@ public sealed class BlocklistFilter : IConnectionFilter private string _lastGenerated; private DateTime _lastWriteUtc; private CancellationTokenSource _cts; + private Timer _sweepTimer; public string Name => "blocklist"; @@ -68,6 +70,7 @@ public sealed class BlocklistFilter : IConnectionFilter var s = BlocklistConfiguration.Settings; _path = ResolvePath(s.File); + _enabled = s.Enabled && _path != null; _interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval; _reportHits = s.ReportHits; _banDuration = s.BanDuration; @@ -91,16 +94,26 @@ public sealed class BlocklistFilter : IConnectionFilter public void Start(CancellationToken token) { - if (_path == null) + if (!_enabled) { - logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); + LogWhyDisabled(); return; } + // The operator's override on this gate, opted into separately. Without it only the generator's + // subtraction covers carve-outs, and that does not cover ban contributions. + if (!ManualAllowlist.Enabled) + { + logger.Warning( + "Blocklist is on but the manual allowlist is not; set \"enabled\" in ip-allowlist.json so a " + + "carve-out also suppresses ban contributions" + ); + } + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); - // A missing file is the shipped default, not an error: the gate stays inert until the poll picks - // up whatever the generator first writes. No restart needed. + // A missing file is not an error: the gate stays inert until the poll picks up whatever the + // generator first writes. No restart needed. if (File.Exists(_path)) { Reload(); // synchronous prime; empty on failure (fail-open) @@ -110,17 +123,47 @@ public sealed class BlocklistFilter : IConnectionFilter logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval); } - // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. - Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); + // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. Only marked when hits + // are reported, so there is nothing to sweep otherwise. + if (_reportHits) + { + _sweepTimer = Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); + } _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); } + private void LogWhyDisabled() + { + if (_path == null) + { + logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); + } + else if (File.Exists(_path)) + { + // An upgraded shard has a list on disk but no "enabled" key, so say so rather than silently + // dropping a gate it was relying on. + logger.Warning( + "Blocklist is off (\"enabled\" false in blocklist.json) but a list is present at \"{Path}\"; " + + "no addresses will be denied", + _path + ); + } + else + { + logger.Information("Blocklist disabled (\"enabled\" false in blocklist.json)"); + } + } + public void Stop() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; + + // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. + _sweepTimer?.Stop(); + _sweepTimer = null; } public bool ShouldDeny(IPAddress address) @@ -153,9 +196,9 @@ public sealed class BlocklistFilter : IConnectionFilter } // Both are asked only once the list has matched, so they cost the common accept nothing. The file - // list is usually redundant because the generator subtracts it — except right after an operator adds - // an entry without regenerating, which is exactly when someone is waiting to get back in. - if (FileAllowlist.Contains(address)) + // list is usually redundant because the generator subtracts it — except right after an operator + // adds an entry without regenerating, which is when someone is waiting to get back in. + if (ManualAllowlist.Contains(address)) { return false; } @@ -248,9 +291,8 @@ public sealed class BlocklistFilter : IConnectionFilter private void Reload() { - // Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not - // one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll; - // capturing after could skip a version entirely. + // Capture the mtime/header BEFORE Load() so they describe the version being parsed. Capturing + // after could skip a version the producer swapped in mid-parse; stale markers only cost a reload. var writeUtc = default(DateTime); try { diff --git a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs index 29b2e5176..d62df0752 100644 --- a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs +++ b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs @@ -46,8 +46,7 @@ public sealed class BlocklistSnapshot /// 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. + /// Malformed lines increment and never throw. /// public static BlocklistSnapshot Build(ReadOnlySpan data, out int parsed, out int skipped) { @@ -183,7 +182,7 @@ public sealed class BlocklistSnapshot } /// - /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for + /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for /// whom would read backwards. The interval machinery is direction-agnostic. /// public bool Contains(IPAddress ip) => IsBanned(ip); diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs index b1376b6a0..5855633f9 100644 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs @@ -21,9 +21,8 @@ using Server.Json; namespace Server.Network.Bans.CrowdSec; /// -/// Loads the from Configuration/crowdsec.json (matching the -/// per-feature JSON config pattern used by AssistantConfiguration). Loaded once; a missing file -/// writes a disabled-by-default template so operators have something to edit. +/// Loads the from Configuration/crowdsec.json. Loaded once; a +/// missing file writes a disabled-by-default template so operators have something to edit. /// public static class CrowdSecConfiguration { diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs index 385534d2a..c900eb90f 100644 --- a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs @@ -304,12 +304,10 @@ public sealed class CrowdSecReporter : IBanReporter } /// - /// Sends with up to 3 attempts total (1 initial + 2 retries), backing off 1s then 2s between - /// attempts, for transient LAPI failures (network blips, 5xx). Backoff uses - /// so it never blocks the thread; a cancellation during backoff propagates as - /// so the drain loop exits cleanly. Returns false (never - /// throws for a send failure) once attempts are exhausted, so the caller can count the drop and keep - /// draining instead of losing the rest of the batch/queue. + /// Up to 3 attempts (1 initial + 2 retries) backing off 1s then 2s, for transient LAPI failures + /// (network blips, 5xx). Backoff uses so it never blocks the thread, and a + /// cancellation during it propagates so the drain loop exits cleanly. Returns false rather than + /// throwing once attempts are exhausted, so the caller counts the drop and keeps draining. /// private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) { diff --git a/Projects/UOContent/Network/Firewall/Firewall.cs b/Projects/UOContent/Network/Firewall/Firewall.cs index 075b47ec3..36c11bbf9 100644 --- a/Projects/UOContent/Network/Firewall/Firewall.cs +++ b/Projects/UOContent/Network/Firewall/Firewall.cs @@ -28,10 +28,10 @@ namespace Server.Network; public static class Firewall { // Single-threaded: the accept path, admin gump/command, TTL expiry timer, and boot load all run on - // the main game loop. No locks, caches, or version counters are needed. See the ban-channel design doc. - // _entries is the authoritative store (gump/persistence/TTL/command all work against it); _index is a - // derived, rebuild-on-demand SortedRangeIndex used only for the accept-path IsBlocked lookup, shared - // with the same sorted-range binary-search primitive the blocklist uses (see BlocklistSnapshot). + // the main game loop, so no locks, caches, or version counters are needed. _entries is the + // authoritative store; _index is a derived, rebuild-on-demand SortedRangeIndex used only for the + // accept-path IsBlocked lookup, over the same primitive the blocklist uses (see BlocklistSnapshot). + // See dev-docs/ip-bans-and-allowlists.md. private static readonly List _entries = []; // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. @@ -152,7 +152,7 @@ public static class Firewall } /// - /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer (Task 2). + /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer. /// internal static void ExpireEntries(long nowTicks) { diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs index c44ceb33d..23075f7ff 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -19,6 +19,7 @@ using System.Globalization; using System.IO; using System.Net; using System.Text; +using System.Threading; using System.Threading.Tasks; using Server.Logging; using Server.Network.Bans; @@ -30,16 +31,11 @@ namespace Server.Network; /// blocked and a flaky connection cannot get one globally banned. /// /// -/// -/// Consulted only after the blocklist has already matched, and again before a ban is contributed, so a -/// normal accept pays nothing for it. An entry is evidence rather than a licence: enough strikes inside the -/// window revokes it. It cannot bootstrap, so it hedges stable addresses and does not replace -/// . See dev-docs/ip-bans-and-allowlists.md. -/// -/// -/// Both dictionaries are game-loop state. Only the file write runs off-loop, over a snapshot taken on the -/// loop. -/// +/// Consulted only after the blocklist has already matched, so a normal accept pays nothing for it. An entry +/// is evidence rather than a licence: enough strikes inside the window revokes it. It cannot bootstrap, so +/// it does not replace . Both dictionaries are game-loop state; only the file +/// write runs off-loop, over a snapshot taken on the loop. +/// See dev-docs/ip-bans-and-allowlists.md. /// public static class LoginAllowlist { @@ -52,6 +48,11 @@ public static class LoginAllowlist // _allowed and cannot be grown by an attacker. private static readonly Dictionary _strikes = []; + // Reused: past ~5,300 entries a fresh UInt128[] is an LOH allocation, once per flush. Grown + // geometrically, never shrunk. + private static UInt128[] _addressBuffer = []; + private static long[] _stampBuffer = []; + private static bool _enabled; private static string _path; private static long _ttlSeconds; @@ -59,6 +60,10 @@ public static class LoginAllowlist private static long _strikeWindowSeconds; private static bool _dirty; + // Loop-only. The writer owns the buffers until it posts completion back, so a flush landing mid-write + // waits rather than overwriting them. + private static bool _writing; + public static int Count => _allowed.Count; private struct Strike @@ -97,10 +102,14 @@ public static class LoginAllowlist var interval = LoginAllowlistConfiguration.Settings.FlushInterval; if (interval <= TimeSpan.Zero) { - interval = TimeSpan.FromMinutes(1); + interval = TimeSpan.FromHours(1); } Timer.DelayCall(interval, interval, Flush); + + // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own. + EventSink.Shutdown += OnShutdown; + EventSink.ServerCrashed += OnCrashed; } /// @@ -197,6 +206,8 @@ public static class LoginAllowlist { _allowed.Clear(); _strikes.Clear(); + _writing = false; + _dirty = false; _enabled = enabled; _ttlSeconds = ttlSeconds; _escalateAfterStrikes = escalateAfterStrikes; @@ -208,28 +219,87 @@ public static class LoginAllowlist private static void Flush() { - if (!_enabled || !_dirty) - { - return; - } - // A save owns the disk and nothing here is urgent. _dirty stays set, so skipping loses nothing. // See the threading policy in CLAUDE.md (rules #3 and #10). - if (World.Saving || World.WorldState == WorldState.PendingSave) + if (!_enabled || !_dirty || _writing || World.Saving || World.WorldState == WorldState.PendingSave) { return; } + var count = Snapshot(out var dropped); + var addresses = _addressBuffer; + var stamps = _stampBuffer; + var path = _path; + + _dirty = false; + _writing = true; + + _ = Task.Run( + () => + { + var written = Write(path, addresses, stamps, count, dropped); + + // _writing and _dirty are loop state, so the writer hands the release back. Rule #10. + Core.LoopContext.Post( + () => + { + _writing = false; + if (!written) + { + _dirty = true; // nothing reached disk; the next flush retries + } + } + ); + } + ); + } + + /// + /// A crash is the case the flush interval cannot cover, so write on the way down. Runs on whichever + /// thread faulted, and the dictionaries are loop state, so it only writes when that is the loop. + /// + private static void OnCrashed(ServerCrashedEventArgs e) + { + if (Thread.CurrentThread == Core.Thread) + { + OnShutdown(); + } + } + + /// Synchronous: nothing schedules after this, so a handed-off write would reach no disk. + private static void OnShutdown() + { + // A write already in flight holds the buffers and has all but the last moments of the list. + if (!_enabled || !_dirty || _writing) + { + return; + } + + var count = Snapshot(out var dropped); + _dirty = false; + + Write(_path, _addressBuffer, _stampBuffer, count, dropped); + } + + /// + /// Prunes expired entries and copies what survives into the shared buffers. Returns the live count; the + /// buffers run longer and everything past it is stale. + /// + private static int Snapshot(out int dropped) + { var nowUnix = ToUnixSeconds(Core.Now); var cutoff = nowUnix - _ttlSeconds; - // Prune and snapshot in one loop-side pass; the writer only sees private copies. Not pooled: - // STArrayPool is single-threaded and these escape to another thread. - var addresses = new UInt128[_allowed.Count]; - var stamps = new long[_allowed.Count]; - var count = 0; + if (_addressBuffer.Length < _allowed.Count) + { + // Geometric so a shard adding addresses one at a time does not reallocate every flush. + var size = Math.Max(_allowed.Count, Math.Max(64, _addressBuffer.Length * 2)); + _addressBuffer = new UInt128[size]; + _stampBuffer = new long[size]; + } - var dropped = 0; + var count = 0; + dropped = 0; foreach (var (address, stamp) in _allowed) { @@ -241,19 +311,13 @@ public static class LoginAllowlist continue; } - addresses[count] = address; - stamps[count] = stamp; + _addressBuffer[count] = address; + _stampBuffer[count] = stamp; count++; } PruneStaleStrikes(nowUnix); - - _dirty = false; - - var path = _path; - var total = count; - - _ = Task.Run(() => Write(path, addresses, stamps, total, dropped)); + return count; } /// Drops tallies whose window has closed. @@ -273,7 +337,8 @@ public static class LoginAllowlist } } - private static void Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) + /// Writes the list out. Returns false when nothing reached disk, so the caller can retry. + private static bool Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) { try { @@ -309,11 +374,14 @@ public static class LoginAllowlist { logger.Information("Login allowlist wrote {Count} entr(ies), dropped {Dropped} past TTL", count, dropped); } + + return true; } catch (Exception e) { // Recoverable: entries are still in memory and the next flush retries. logger.Warning(e, "Could not write the login allowlist to \"{Path}\"", path); + return false; } } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs index 08f04ecf2..5c673eb10 100644 --- a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs @@ -21,9 +21,8 @@ using Server.Json; namespace Server.Network; /// -/// Loads the from Configuration/login-allowlist.json (matching -/// the per-feature JSON config pattern used by BlocklistConfiguration). Loaded once; a missing file -/// writes a template so operators have something to edit. +/// Loads the from Configuration/login-allowlist.json. Loaded +/// once; a missing file writes a template so operators have something to edit. /// public static class LoginAllowlistConfiguration { @@ -82,11 +81,12 @@ public record LoginAllowlistSettings public TimeSpan Ttl { get; set; } = TimeSpan.FromDays(90); /// - /// How often a changed list is written out. A crash loses at most this much, and an entry is re-earned by - /// the next login. + /// How often a changed list is written out. A clean shutdown always writes, so this only bounds what a + /// crash loses — and an entry is re-earned by the next login. Hourly against a 90-day TTL, because each + /// flush walks the whole list on the game loop. /// [JsonPropertyName("flushInterval")] - public TimeSpan FlushInterval { get; set; } = TimeSpan.FromMinutes(1); + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromHours(1); /// /// How many suppressed contributions inside revoke an address's entry. Past diff --git a/Projects/UOContent/Network/Blocklist/FileAllowlist.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs similarity index 83% rename from Projects/UOContent/Network/Blocklist/FileAllowlist.cs rename to Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs index 0d5582713..20e351457 100644 --- a/Projects/UOContent/Network/Blocklist/FileAllowlist.cs +++ b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs @@ -2,7 +2,7 @@ * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * - * File: FileAllowlist.cs * + * File: ManualAllowlist.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 * @@ -32,15 +32,15 @@ namespace Server.Network.Bans; /// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is /// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address /// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the -/// next regeneration. Unconditional, unlike , but still no shield against a -/// manual ban — see . +/// next regeneration. Opt-in via ip-allowlist.json's enabled, since the poll runs for the +/// whole uptime; no shield against a manual ban either — see . /// -public static class FileAllowlist +public static class ManualAllowlist { - private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist)); + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ManualAllowlist)); - // Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile - // reference swap is the whole synchronization story — readers see the old or the new snapshot, whole. + // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference + // swap is the whole synchronization story: readers see the old or the new snapshot, whole. private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; private static string[] _patterns = []; @@ -53,24 +53,51 @@ public static class FileAllowlist /// True when an operator listed this address. Safe before . public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address); + /// True when the shard is reading allowlist files. Safe before . + public static bool Enabled { get; private set; } + public static void Initialize() { - // BlocklistFilter.Register ran during the Configure sweep, so the settings are populated. - var settings = BlocklistConfiguration.Settings; + ManualAllowlistConfiguration.Load(); + var settings = ManualAllowlistConfiguration.Settings; if (settings == null) { return; } - _patterns = ResolvePaths(settings.AllowlistFiles); + _patterns = ResolvePaths(settings.Files); _interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval; - if (_patterns.Length == 0) + if (!settings.Enabled) { - logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)"); + // The generator still subtracts these files, so a carve-out an operator already wrote looks + // like it works right up until a behavioural detection contributes the address anyway. + var present = ExpandPaths().Length; + _patterns = []; + + if (present > 0) + { + logger.Warning( + "Manual allowlist is off (\"enabled\" false in ip-allowlist.json) but {Count} allowlist file(s) " + + "are present; those carve-outs will not suppress ban contributions", + present + ); + } + else + { + logger.Information("Manual allowlist disabled (\"enabled\" false in ip-allowlist.json)"); + } + return; } + if (_patterns.Length == 0) + { + logger.Information("Manual allowlist disabled (\"files\" empty in ip-allowlist.json)"); + return; + } + + Enabled = true; Reload(); _cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token); @@ -197,7 +224,7 @@ public static class FileAllowlist } catch (Exception e) { - logger.Warning(e, "File allowlist reload check failed; keeping last snapshot ({Count})", Count); + logger.Warning(e, "Manual allowlist reload check failed; keeping last snapshot ({Count})", Count); } } } @@ -246,7 +273,7 @@ public static class FileAllowlist _lastStamp = stamp; logger.Information( - "File allowlist loaded {Count} range(s) from {Files} file(s)", + "Manual allowlist loaded {Count} range(s) from {Files} file(s)", next.Count, files ); diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs new file mode 100644 index 000000000..dd21fe97c --- /dev/null +++ b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs @@ -0,0 +1,83 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ManualAllowlistConfiguration.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.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/ip-allowlist.json. Loaded once; +/// a missing file writes a template so operators have something to edit. +/// +public static class ManualAllowlistConfiguration +{ + private const string _path = "Configuration/ip-allowlist.json"; + + public static ManualAllowlistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new ManualAllowlistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . Its own file rather than a corner of +/// blocklist.json: the blocklist is only one of two consumers, and the other +/// () works on a shard that runs no blocklist at all. +/// +public record ManualAllowlistSettings +{ + /// + /// Whether the shard reads at all. Off by default: reading them costs a poll for + /// the whole uptime, which no shard should pay before an operator has written a carve-out. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same + /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an + /// entry also suppresses ban contributions, which the generator alone cannot do. + /// + /// + /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds + /// without anyone editing this file. + /// + [JsonPropertyName("files")] + public string[] Files { get; set; } = ["Configuration/ip-allowlist*.txt"]; + + /// How often the files are checked for changes. Reloads only happen when one actually changed. + [JsonPropertyName("reloadInterval")] + public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/dev-docs/ip-bans-and-allowlists.md b/dev-docs/ip-bans-and-allowlists.md index 5588bb19e..40320efe5 100644 --- a/dev-docs/ip-bans-and-allowlists.md +++ b/dev-docs/ip-bans-and-allowlists.md @@ -24,7 +24,7 @@ Filters are consulted in registration order, first denial wins: | Filter | Source | Scope | |---|---|---| | `firewall` | `Configuration/firewall.json`, mutable in-game | Admin-curated, permanent | -| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries) | Reputation feeds | +| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries, opt-in) | Reputation feeds | | `auto-denylist` | In-memory, 15 min | What this shard just caught misbehaving | ### Contributing a ban @@ -38,7 +38,7 @@ Two, with different authority: | List | Source | Revocable? | Covers | |---|---|---|---| -| `FileAllowlist` | every `ip-allowlist*.txt` | No — an operator said so | Blocking **and** escalation | +| `ManualAllowlist` | every `ip-allowlist*.txt` (opt-in) | No — an operator said so | Blocking **and** escalation | | `LoginAllowlist` | Earned by authenticating, 90-day TTL | Yes — 10 strikes/hour | Blocking **and** escalation | Both are consulted **only after the blocklist has already matched**, so a normal accept — the one an @@ -66,16 +66,22 @@ If none of those match, they may be inside a **CIDR** in the blocklist, or held ### 2. Add them to the allowlist -One entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This file -is yours; the generator creates it once and never rewrites it. +Set `"enabled": true` in `Configuration/ip-allowlist.json` first — it is off by default, so a shard that +has never written a carve-out does not poll for one. The shard logs a warning at startup if allowlist files +are present while the flag is off. + +Then one entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This +file is yours; the generator creates it once and never rewrites it. ``` 203.0.113.42 # shard owner, listed via a shared upstream address 198.51.100.0/24 # a whole range if the ISP rotates within it ``` -The shard reloads within `reloadInterval` (60s default). **No restart, and no need to re-run the -generator.** From that point the address is neither blocked nor contributed. +With the flag on, the shard reloads within `reloadInterval` (60s default). **No restart, and no need to +re-run the generator.** From that point the address is neither blocked nor contributed. With the flag off +the generator still subtracts the file at generation time, so the address stops being *blocked* — but a +behavioural detection can still contribute it, which is the case the flag exists to cover. ### 3. Clear any ban that already exists @@ -123,8 +129,10 @@ where your players actually are, and a carve-out names a real network, so you bu ``` That writes `ip-allowlist-starlink.txt` beside the blocklist, and every `ip-allowlist*.txt` there is -subtracted — both by the generator and by the shard, with no config edit. Starlink costs about 0.1% of the -list. Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. +subtracted by the generator with no config edit. For the shard to read them too — which is what also stops +a carve-out address being *contributed* by a behavioural detection — set `enabled` in `ip-allowlist.json`; +it is off by default so no shard polls for files it never wrote. Starlink costs about 0.1% of the list. +Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. Carve-out files carry an `asn=` marker in their header, which is how `-RefreshCarveouts` finds them. A hand-written allowlist has no marker and is never rewritten. @@ -155,6 +163,12 @@ Escalation is **immediate**, on the first detection: a 15-minute local hold plus (4h) contribution. There is no N-connection threshold; the strike counter governs only revoking a `LoginAllowlist` entry. +The local hold runs 15 minutes from the **first** detection and is never extended by later ones, so an +address that keeps trying is released on schedule rather than held indefinitely. It does not get a free +run: the rate limiter sits *ahead* of the connection filters, so a flooder is re-reported and re-held on +its next attempt. Not refreshing is what keeps the holds in expiry order, which is what makes retiring +lapsed ones cost the number expiring rather than the number held. + ### What is deliberately NOT detected **Do not add rules based on arrival framing.** TCP has no message boundaries, so the network, the OS or a @@ -177,21 +191,27 @@ firewalled off. Shortening the 5s handshake window has been tried and broke real - **An allowlist cannot bootstrap.** A `LoginAllowlist` entry is only earned by getting in, so it can never repair an existing false positive, and it is weakest on rotating CGNAT — a player whose lease moved is a - stranger again. `FileAllowlist` is the fix for that, which is why it is manual. + stranger again. `ManualAllowlist` is the fix for that, which is why it is manual — and opt-in, via + `ip-allowlist.json`. - **A never-logged-in player on a shared address can still be caught**, for up to `badConnectDuration`, if a co-tenant misbehaves. Accepted: it is 4h and self-healing. The cheapest lever is `badConnectDuration`. - **`MaxConnections` (4096) is a hard ceiling.** The accept gate runs *after* the kernel completed the TCP handshake, so a blocklist match saves the socket setup and the `NetState` slot but never the connection itself. Only an upstream L4 proxy or edge scrubbing moves that cost off the shard. +- **The `auto-denylist` stops tracking at `maxEntries`.** Past it a detection still disconnects the + connection, but the address is not held, so it pays full detection cost on every reconnect instead of a + cheap accept-gate deny. The default is sized for the 50k–250k distinct-source floods seen in practice; a + flood past it wants upstream scrubbing rather than a larger cap, which only buys a longer on-loop scan. ## Configuration | File | Controls | |---|---| | `bans.json` | `reportRateLimitTrips`, `autoBanDuration`, `reportBadConnects`, `badConnectDuration` | -| `blocklist.json` | `file`, `allowlistFiles` (wildcards allowed), `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | +| `blocklist.json` | `enabled` (default `false`), `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | +| `ip-allowlist.json` | `enabled` (default `false`), `files` (wildcards allowed), `reloadInterval` | | `login-allowlist.json` | `enabled`, `file`, `ttl`, `flushInterval`, `escalateAfterStrikes`, `strikeWindow` | -| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` | +| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` (default `324,449` — sized for the floods seen in practice; see the remark on the setting before raising it) | | `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | | `firewall.json` | Admin-curated entries | @@ -208,7 +228,7 @@ A shard fronted by an upstream proxy can disable all of it and register nothing. | `Projects/Server/Network/Bans/BanReasons.cs` | Reason slugs + the behavioural opt-in set | | `Projects/UOContent/Network/BanExemptions.cs` | Combines both allowlists into one answer | | `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | -| `Projects/UOContent/Network/Blocklist/FileAllowlist.cs` | Operator carve-outs, read from the allowlist files | +| `Projects/UOContent/Network/Blocklist/ManualAllowlist.cs` | Operator carve-outs, read from the allowlist files | | `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by authenticating | | `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold | | `Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs` | LAPI contribution sink | diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index af47a1e06..639963cfd 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -511,9 +511,9 @@ Rules: 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. +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 From be3a08513f8e68a9d957fe369b806a05c019ce75 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:14:38 -0700 Subject: [PATCH 45/64] fix: Fixes PlayerConstructed stacking/BODs (#2579) ### Summary * Removes player constructed as a requirement for BODs. * When two items stack and they don't match player constructed flags, the resulting stack loses the flag. --- .../Items/PlayerConstructedStackingTests.cs | 41 ------------------- Projects/Server/Items/Item.cs | 8 +++- .../UOContent/Engines/Bulk Orders/SmallBOD.cs | 6 +-- 3 files changed, 7 insertions(+), 48 deletions(-) diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs index 05595068a..955a87293 100644 --- a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -24,26 +24,6 @@ public class PlayerConstructedStackingTests private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; - [Fact] - public void CanStackWith_IsFalseWhenProvenanceDiffers() - { - var bought = MakeStack((Serial)0x1, 5, false); - var crafted = MakeStack((Serial)0x2, 5, true); - - try - { - // Both orders must fail. Whichever is the receiver decides the merged pile's flag, - // so allowing either one means the result is decided by drag direction. - Assert.False(bought.CanStackWith(crafted)); - Assert.False(crafted.CanStackWith(bought)); - } - finally - { - bought.Delete(); - crafted.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] @@ -63,27 +43,6 @@ public class PlayerConstructedStackingTests } } - [Fact] - public void StackWith_RefusesToMergeAcrossProvenance() - { - var bought = MakeStack((Serial)0x1, 5, false); - var crafted = MakeStack((Serial)0x2, 5, true); - - try - { - Assert.False(bought.StackWith(null, crafted, false)); - Assert.Equal(5, bought.Amount); - Assert.Equal(5, crafted.Amount); - Assert.False(bought.PlayerConstructed); - Assert.False(crafted.Deleted); - } - finally - { - bought.Delete(); - crafted.Delete(); - } - } - [Theory] [InlineData(false)] [InlineData(true)] diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 889be4619..dae64a3f5 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -2350,7 +2350,6 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert dropped.ItemID == ItemID && dropped.Hue == Hue && dropped.Name == Name && - dropped.PlayerConstructed == PlayerConstructed && dropped.Amount + Amount <= 60000 && dropped != this; @@ -2369,6 +2368,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; + if (PlayerConstructed != dropped.PlayerConstructed) + { + PlayerConstructed = false; + } + dropped.Delete(); if (playSound && from != null) @@ -3451,7 +3455,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert for (var i = 0; i < props.Length; i++) { var p = props[i]; - if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) + if (p.GetCustomAttribute(true) != null || !p.CanRead || !p.CanWrite) { continue; } diff --git a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs index 746da8687..6356780de 100644 --- a/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs +++ b/Projects/UOContent/Engines/Bulk Orders/SmallBOD.cs @@ -137,11 +137,7 @@ public abstract partial class SmallBOD : BaseBOD { var material = GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None); - if (!item.PlayerConstructed) - { - from.SendLocalizedMessage(1045169); // The item is not in the request. - } - else if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) + if (Material >= BulkMaterialType.DullCopper && Material <= BulkMaterialType.Valorite && material != Material) { from.SendLocalizedMessage(1045168); // The item is not made from the requested ore. } From fd27b7a3c9e8705b88db0b8331673728a46bff98 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:22:04 -0700 Subject: [PATCH 46/64] chore: Simplify server requirements section in README (#2582) Removed unnecessary details about game logic and server requirements. --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index b8d49f544..01a368804 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,7 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc | Medium (50–200) | 4–8 | 8 GB | NVMe | | Large (200+) | 8+, high clock | 16 GB+ | NVMe | -Game logic is single-threaded, so **single-core clock speed matters more than core count**, and -**dedicated vCPU matters more than either** — burstable or shared plans throttle once credits run -out, which is the most common cause of unexplained lag spikes. Save size drives RAM more than -player count does. - -See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for the reasoning and tuning -options. +See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for more information. #### Development [![git](https://img.shields.io/badge/-git-F05032?logo=git&logoColor=F05032&labelColor=222222)](https://git-scm.com/downloads) From 971d7b6a778f55d94891f6d376b9f4dad923ff78 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:56:00 -0700 Subject: [PATCH 47/64] fix: stop items from insta-decaying when decay eligibility is restored without a move (#2583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A GM flipping `Movable` back on for a long-frozen item made it vanish within one scheduler tick. The setter registered the item with a deadline computed from its stale `LastMoved`, so `ProcessActiveQueue` deleted it almost immediately. The pre-#2311 save-time sweep had the same semantics, just hidden behind the save cadence. The same failure existed for `Visible` and `Spawner` transitions. `LastMoved` is deliberately left meaning actual movement — it feeds vendor inventory expiry and house moving-crate checks — so the fix does not rewrite it for state changes. ## Changes - **`DecayResetTime`** (CompactInfo-backed): the decay countdown runs from the later of `LastMoved` and this stamp. `RestartDecay()` stamps it only when the item can decay and the stamp extends the current deadline, so hot paths with a fresh `LastMoved` allocate nothing. - **`Movable`/`Visible`/`Spawner` setters** call `RestartDecay()` instead of registering a stale deadline. - **Region-refusal retry** in `DecayScheduler` uses `RestartDecay()` instead of rewriting `LastMoved`. - **Persistence**: the stamp survives save/load as a `WriteDeltaTime` delta under `SaveFlag.DecayReset` (to become `WriteAnchoredTime` once the save-time anchor is ported) (Item serialization v10), so a restart mid-window no longer deletes the item. - **`LastMoved` setter** drops a superseded stamp so the `CompactInfo` can collapse instead of being held (~40 bytes) forever. - **Raw `Map` setter** now counts as a move for parentless items: it stamps `LastMoved` and updates decay registration, closing the gap where an item moved out of `Map.Internal` via the setter never decayed. - **`LiftItemDupe`**: the remainder of a partially lifted *ground* stack was placed via raw `Location`/`Map` assignments and never enrolled for decay (lingering-trash leak since #2311) — now enrolled via the Map setter. Parented remainders get their map from `AddItem` (parent first, then map), so container splits never transit the scheduler. --- .../Tests/Items/DecayRegistrationTests.cs | 266 ++++++++++++++++++ Projects/Server/Items/DecayScheduler.cs | 4 +- Projects/Server/Items/Item.cs | 129 ++++++++- Projects/Server/Mobiles/Mobile.cs | 8 +- 4 files changed, 397 insertions(+), 10 deletions(-) diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 0e48977ef..59d736c6f 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -208,6 +208,272 @@ public class DecayRegistrationTests item.Delete(); } + // Unfreezing an item with a stale LastMoved must grant a fresh decay window, + // not delete it on the next tick. + [Fact] + public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Movable = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // Same transition through the Visible setter: unhiding a long-hidden item. + [Fact] + public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Visible = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // A refusal restarts the countdown without rewriting LastMoved. + [Fact] + public void RefusedDecay_DoesNotRewriteLastMoved() + { + var start = Core._now; + + try + { + var item = new RefusesDecayItem(); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; + + AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. + [Fact] + public void FreshDecayWindow_SurvivesSerialization() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + var expected = item.ScheduledDecayTime; + + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); + + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); + + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); + + item.Delete(); + copy.Delete(); + } + finally + { + Core._now = start; + } + } + + // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. + [Fact] + public void MovingAnItem_ClearsASupersededDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + Core._now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Losing decay eligibility makes the stamp meaningless; it must be dropped so the + // CompactInfo is not held for as long as the item stays ineligible. + [Fact] + public void ItemBecomingIneligible_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + item.Movable = false; + + Assert.Equal(default, item.DecayResetTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) + // must also drop the stamp. + [Fact] + public void StampedItemAddedToContainer_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var pack = new Container(0xE75); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + pack.AddItem(item); + + Assert.Equal(default, item.DecayResetTime); + + pack.Delete(); + } + finally + { + Core._now = start; + } + } + + // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must + // enroll an untracked item for decay. + [Fact] + public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() + { + var item = new Item(0x1234); + Assert.False(DecayScheduler.IsRegistered(item)); + + item.Map = Map.Felucca; + + Assert.True(item.CanDecay()); + Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked."); + + item.Delete(); + } + + // LiftItemDupe places the remainder of a partially lifted ground stack via raw + // Location/Map assignments, with no MoveToWorld fallback: it must still be tracked. + [Fact] + public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() + { + var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); + + var remainder = Mobile.LiftItemDupe(stack, 3); + + Assert.NotNull(remainder); + Assert.Equal(7, remainder.Amount); + Assert.Null(remainder.Parent); + Assert.Equal(Map.Felucca, remainder.Map); + Assert.True( + DecayScheduler.IsRegistered(remainder), + "The remainder of a partially lifted ground stack must be tracked for decay." + ); + + stack.Delete(); + remainder.Delete(); + } + // Dropping into a container must untrack; taking it back out to the ground must re-track. [Fact] public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() diff --git a/Projects/Server/Items/DecayScheduler.cs b/Projects/Server/Items/DecayScheduler.cs index 694d9b6b2..70dc1ef27 100644 --- a/Projects/Server/Items/DecayScheduler.cs +++ b/Projects/Server/Items/DecayScheduler.cs @@ -311,7 +311,7 @@ public class DecayScheduler : Timer if (timeUntilDecay > _bucketInterval) { - // Item was moved (SetLastMoved called) - re-bucket or move to overflow + // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow if (timeUntilDecay > _totalBucketSpan) { // Extended beyond total span - move to overflow @@ -429,7 +429,7 @@ public class DecayScheduler : Timer { // Refused by the region. Restart the clock rather than dropping the item, which has // already left the queue; re-registering as-is would spin on a due time in the past. - item.SetLastMoved(); + item.RestartDecay(); } } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index dae64a3f5..a2b63a821 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -335,7 +335,25 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - public DateTime LastMoved { get; set; } + private DateTime _lastMoved; + + public DateTime LastMoved + { + get => _lastMoved; + set + { + _lastMoved = value; + + // A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse. + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -373,7 +391,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -389,7 +407,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -845,7 +863,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(9); // version + writer.Write(10); // version var flags = SaveFlag.None; @@ -955,6 +973,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { flags |= SaveFlag.SavedFlags; } + + if (info.m_DecayReset > LastMoved) + { + flags |= SaveFlag.DecayReset; + } } if (info == null || info.m_Weight < 0) @@ -1001,6 +1024,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); /* end */ + if (GetSaveFlag(flags, SaveFlag.DecayReset)) + { + //TODO Use WriteAnchoredTime once the save-time anchor is ported + writer.WriteDeltaTime(info.m_DecayReset); + } + if (GetSaveFlag(flags, SaveFlag.Direction)) { writer.Write((byte)m_Direction); @@ -1318,6 +1347,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert OnMapChange(); + if (m_Parent == null) + { + // A map change is a move; nothing else updates decay registration for a raw Map change. + SetLastMoved(); + } + if (old == null || old == Map.Internal) { InvalidateProperties(); @@ -1548,7 +1583,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -1742,6 +1777,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert || info.m_HeldBy != null || info.m_BlessedFor != null || info.m_Spawner != null + || info.m_DecayReset != default || info.m_TempFlags != 0 || info.m_SavedFlags != 0 || info.m_Weight >= 0; @@ -2326,7 +2362,64 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual bool OnDecay() => CanDecay() && Region.Find(Location, Map).OnDecay(this); - public DateTime ScheduledDecayTime => LastMoved + DecayTime; + public DateTime ScheduledDecayTime + { + get + { + var reset = DecayResetTime; + var lastMoved = LastMoved; + + return (reset > lastMoved ? reset : lastMoved) + DecayTime; + } + } + + /// + /// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it. + /// The decay countdown runs from the later of this and . + /// + public DateTime DecayResetTime + { + get => LookupCompactInfo()?.m_DecayReset ?? default; + private set + { + if (value == default) + { + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + else + { + AcquireCompactInfo().m_DecayReset = value; + } + } + } + + /// + /// Restarts the decay countdown without touching : call when decay + /// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a + /// stale would otherwise decay the item on the next tick. + /// Stamps only when that extends the current deadline, then + /// updates the scheduler registration. + /// + public void RestartDecay() + { + if (CanDecay()) + { + var now = Core.Now; + + if (ScheduledDecayTime < now + DecayTime) + { + DecayResetTime = now; + } + } + + UpdateDecayRegistration(); + } public void UpdateDecayRegistration() { @@ -2336,6 +2429,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { DecayScheduler.Register(this); } + else + { + // No countdown to anchor while ineligible; drop the stamp so the CompactInfo + // can collapse. Re-eligibility always re-anchors. + DecayResetTime = default; + } } public void SetLastMoved() @@ -2673,6 +2772,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { + case 10: case 9: case 8: case 7: @@ -2698,6 +2798,18 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } } + if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) + { + var reset = reader.ReadDeltaTime(); + + // LastMoved is stored at whole-minute precision; keep the stamp only + // while it still extends the deadline. + if (reset > LastMoved) + { + DecayResetTime = reset; + } + } + if (GetSaveFlag(flags, SaveFlag.Direction)) { m_Direction = (Direction)reader.ReadByte(); @@ -4361,6 +4473,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public ISpawner m_Spawner; + public DateTime m_DecayReset; + public int m_TempFlags; public double m_Weight = -1; @@ -4399,6 +4513,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert IntWeight = 0x01000000, SavedFlags = 0x02000000, NullWeight = 0x04000000, - PlayerConstructed = 0x08000000 + PlayerConstructed = 0x08000000, + DecayReset = 0x10000000 } } diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 6755c244b..0e88449e3 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -5250,7 +5250,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; - item.Map = oldItem.Map; + + // A parented remainder gets its map from AddItem (parent first, then map), keeping the + // split off the decay scheduler; a ground remainder is placed and enrolled here. + if (oldItem.Parent == null) + { + item.Map = oldItem.Map; + } oldItem.OnAfterDuped(item); From 541dbc5ac5d45a8601eae765d0192163f4393f0e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:48:53 -0700 Subject: [PATCH 48/64] feat: Bumps dependencies. Introduces Serialization Generator v3 (#2584) ### Summary * Upgrades Serialization Generator to v3. This contains numerous bug fixes and a significant performance improvement. * Bumps other dependencies. --- .config/dotnet-tools.json | 2 +- Directory.Build.props | 2 +- Projects/BuildTool/BuildTool.csproj | 1 - Projects/Server.Tests/Server.Tests.csproj | 5 ++--- Projects/Server/Server.csproj | 7 +++---- Projects/UOContent.Tests/UOContent.Tests.csproj | 5 ++--- Projects/UOContent/UOContent.csproj | 9 ++++----- 7 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index da66d8d0c..8b18d54e8 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "2.14.3", + "version": "3.0.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/Directory.Build.props b/Directory.Build.props index 30bf85099..c832f1006 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -71,7 +71,7 @@ $(DefineConstants);EVENT_LOOP_PROFILING - + diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj index b3f37cf7a..983000240 100644 --- a/Projects/BuildTool/BuildTool.csproj +++ b/Projects/BuildTool/BuildTool.csproj @@ -17,6 +17,5 @@ - diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 1be789258..4bae245cb 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,17 +5,16 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 49d8d496a..522701b78 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -37,11 +37,10 @@ - + - - - + + diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 712246f72..1c73f6eb4 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,9 +4,9 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -15,7 +15,6 @@ - diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index d9961f38d..5c7b74214 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -41,18 +41,17 @@ false - + - + - - - + + From 126a10ce5340117ef8b020c9d7dbdc782ef47e44 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:59:39 -0700 Subject: [PATCH 49/64] feat: anchored-time infrastructure with a save-start anchor in idx v5 (#2585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The save-stability infrastructure consumed by generator v3's `[AnchoredDateTime]`: anchored timestamps are written as **absolute values** and re-based once at load by the elapsed time since the save started — so downtime doesn't age them, and an unchanged entity serializes to identical bytes (the prerequisite for replacing delta-time encodings, which rewrite every entity on every save). ## Design - **`WriteAnchoredTime` / `ReadAnchoredTime`** on `IGenericWriter`/`IGenericReader`. The read side applies the reader's `AnchoredTimeShift`; `Min/MaxValue` sentinels pass through unshifted, and shifts saturate instead of overflowing. - **`World.SaveStartTime`** is stamped the moment the world freezes for a snapshot — one anchor for the entire save, no per-persistence skew. - **idx v5**: the anchor ticks sit in the header right after the version. The anchor travels with the file it re-anchors, so a single idx+bin pair restored from a backup is self-describing, and anchor presence is guaranteed by the same version gate as the record format — there is no separate anchor file to lose. - **The shift rides the reader instance** (`BufferReader`, `UnmanagedDataReader`, `BinaryFileReader` delegating), not a static — parallel per-persistence loads and ad-hoc restores each see their own file's anchor. idx v4 and older read with a zero shift. ## Scope Behavior-neutral: nothing serializes anchored values yet (`Item.DecayResetTime` and the `[DeltaDateTime]` field migrations come separately, with their own version bumps). Saves written from this branch are idx v5; loading v4/v3 saves is unchanged and remains pinned by the existing hand-written-header tests. ## Testing - Unit round-trips: exact with zero shift, shifted read, sentinel passthrough, saturation, Local→UTC normalization. - End-to-end through the real worker/segment-log pipeline: an anchored timestamp re-bases across a simulated two-hour downtime via the idx v5 header. - Full suites green: Server.Tests 835/835, UOContent.Tests 708/708 (including the existing v4/v3 idx loading tests). --- .../Tests/Serialization/AnchoredTimeTests.cs | 189 ++++++++++++++++++ .../Server/Serialization/BinaryFileReader.cs | 6 + Projects/Server/Serialization/BufferReader.cs | 2 + Projects/Server/Serialization/BufferWriter.cs | 15 ++ .../Serialization/GenericEntityPersistence.cs | 28 ++- .../Server/Serialization/IGenericReader.cs | 31 +++ .../Server/Serialization/IGenericWriter.cs | 1 + .../Serialization/UnmanagedDataReader.cs | 2 + Projects/Server/World/World.cs | 10 + 9 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs new file mode 100644 index 000000000..a99b9850c --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Server.Tests; + +public class AnchoredTimeTests +{ + private static (BufferWriter Writer, Func Read) CreateRoundTrip() + { + var writer = new BufferWriter(new byte[64], true); + return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift }); + } + + [Fact] + public void AnchoredTime_RoundTripsExactly_WithZeroShift() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_AppliesShiftOnRead() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + var shift = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value + shift, read(shift).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SentinelsPassThroughUnshifted() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MinValue); + writer.WriteAnchoredTime(DateTime.MaxValue); + + var reader = read(TimeSpan.FromDays(2)); + Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime()); + Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SaturatesInsteadOfOverflowing() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1)); + + Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_NormalizesLocalKindOnWrite() + { + var (writer, read) = CreateRoundTrip(); + var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local); + + writer.WriteAnchoredTime(local); + + Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); + } +} + +internal class AnchoredEntity : ISerializable +{ + public AnchoredEntity(Serial serial) => Serial = serial; + + public Serial Serial { get; } + public DateTime Created { get; set; } = DateTime.UtcNow; + public bool Deleted => false; + + public DateTime LastRested { get; set; } + + public void Delete() + { + } + + public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested); + + public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime(); +} + +[Collection("Sequential Server Tests")] +public class AnchoredTimePersistenceTests +{ + private class AnchoredPersistence : GenericEntityPersistence + { + public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF) + { + } + } + + /// + /// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps + /// by the elapsed time since the save started, so downtime does not age them. + /// + [Fact] + public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() + { + var previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + + var source = new SerializationChunkSource(); + var workers = new SerializationThreadWorker[2]; + for (var i = 0; i < workers.Length; i++) + { + workers[i] = new SerializationThreadWorker(i, source); + workers[i].AllocateHeap(); + } + + var previousWorkers = World._threadWorkers; + World._threadWorkers = workers; + + var previousSaveStart = World.SaveStartTime; + + var persistence = new AnchoredPersistence(2100); + AnchoredPersistence loaded = null; + + var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + try + { + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); + + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; + + foreach (var worker in workers) + { + worker.Wake(); + } + + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + source.Flush(); + foreach (var worker in workers) + { + worker.Sleep(); + } + + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); + + loaded = new AnchoredPersistence(2101); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; + + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); + } + finally + { + World.SaveStartTime = previousSaveStart; + persistence.Unregister(); + loaded?.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + + World._threadWorkers = previousWorkers; + AssemblyHandler.Assemblies = previousAssemblies; + Directory.Delete(dir, true); + } + } +} diff --git a/Projects/Server/Serialization/BinaryFileReader.cs b/Projects/Server/Serialization/BinaryFileReader.cs index ec76516aa..f8b51c182 100644 --- a/Projects/Server/Serialization/BinaryFileReader.cs +++ b/Projects/Server/Serialization/BinaryFileReader.cs @@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader /// public long Position => _reader.Position; + public TimeSpan AnchoredTimeShift + { + get => _reader.AnchoredTimeShift; + set => _reader.AnchoredTimeShift = value; + } + public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index 846e9e5dc..bd37f4ff6 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -37,6 +37,8 @@ public class BufferReader : IGenericReader public long Position => _position; public long BufferSize => _buffer.Length; + public TimeSpan AnchoredTimeShift { get; set; } + public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index b811d68cb..68fd971ba 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -407,6 +407,21 @@ public class BufferWriter : IGenericWriter Write(value.Ticks - DateTime.UtcNow.Ticks); } + /// + /// Writes the absolute value; re-bases it + /// by the elapsed time since the save started, so downtime does not age it and an + /// unchanged value serializes to identical bytes. + /// + public void WriteAnchoredTime(DateTime value) + { + if (value.Kind == DateTimeKind.Local) + { + value = value.ToUniversalTime(); + } + + Write(value.Ticks); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(IPAddress value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index a49f213eb..f27b22740 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -114,9 +114,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer using var binFs = new FileStream( Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024 ); - // v4 records are fixed-width 26 bytes; the header carries the type table - // (name lengths vary — 64 bytes per entry is a staging hint, not a contract). - var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; + // v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor + // and the type table (name lengths vary — 64 bytes per entry is a staging hint, not + // a contract). + var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; @@ -142,7 +143,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer binPosition += _selfLength; } - idx.Write(4); // Version + idx.Write(5); // Version + + // One anchor for the whole save: the world is frozen from the moment it is stamped. + idx.Write(World.SaveStartTime.Ticks); // The type table is fully known at freeze (AddEntity diverts to the pending // queues while saving) and is written before the records so the loader can @@ -494,6 +498,14 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var version = dataReader.ReadInt(); + if (version >= 5) + { + // Re-base anchored timestamps by the elapsed time since the save started. + var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); + var shift = Core.Now - anchor; + _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; + } + if (version >= 4) { DeserializeIndexesV4(dataReader, entities); @@ -660,6 +672,9 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private static List _toDelete; + // From the loaded idx (v5+); zero when the save predates the anchor. + private TimeSpan _anchoredTimeShift; + private unsafe void InternalDeserialize(string filePath, int index, Dictionary typesDb) { using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open); @@ -667,7 +682,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + AnchoredTimeShift = _anchoredTimeShift + }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index 0244a5166..4821a708b 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -52,6 +52,37 @@ public interface IGenericReader var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc) }; } + + /// + /// Elapsed time between the loaded save starting and this load, applied by + /// . Zero when the source carries no anchor. + /// + TimeSpan AnchoredTimeShift => TimeSpan.Zero; + + DateTime ReadAnchoredTime() + { + var value = ReadDateTime(); + + if (value == DateTime.MinValue || value == DateTime.MaxValue) + { + return value; + } + + var shift = AnchoredTimeShift; + if (shift == TimeSpan.Zero) + { + return value; + } + + var ticks = value.Ticks + shift.Ticks; + + if (ticks >= DateTime.MaxValue.Ticks) + { + return DateTime.MaxValue; + } + + return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc); + } decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]); int ReadEncodedInt() { diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 9c9563139..22579ab1a 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -41,6 +41,7 @@ public interface IGenericWriter void WriteEncodedInt(int value); void Write(DateTime value); void WriteDeltaTime(DateTime value); + void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); void Write(Point3D value); diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index 7c8b5da1e..aa3916ee9 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader /// public long Position { get; private set; } + public TimeSpan AnchoredTimeShift { get; set; } + /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 2c9e6d216..4111ef0d1 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -93,6 +93,12 @@ public static class World public static string SavePath { get; private set; } public static WorldState WorldState { get; private set; } public static bool Saving => WorldState == WorldState.Saving; + + /// + /// UTC time the current or most recent world save started. Written into save indexes so + /// anchored timestamps can be re-based by the downtime at load. + /// + public static DateTime SaveStartTime { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -287,6 +293,10 @@ public static class World WorldState = WorldState.Saving; + // The world is frozen from here: one anchor for the whole save. Written into save + // indexes so anchored timestamps can be re-based by the downtime at load. + SaveStartTime = Core.Now; + Broadcast(0x35, true, "The world is saving, please wait."); logger.Information("Saving world"); From 73f9688083300b883be0fb68052628e1ddb4658f Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:54:02 -0700 Subject: [PATCH 50/64] feat: adopt serialization generator v4 (field-side linkage, anchored timers) (#2586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adopts ModernUO.Serialization 4.0.0 across the engine. Three commits, reviewable independently: 1. **Package + tool bump to 4.0.0** (`Server.csproj`, `UOContent.csproj`, `dotnet-tools.json`). 2. **Timers → `[DeserializeTimer]`** — the 8 drifting timers (BaseLight, TreasureMapChest, MarkContainer, FillableContainer, DeathRobe, DecayedCorpse, Corpse, BaseEscortable) now store their next tick as **anchored time**: server downtime no longer consumes the remaining delay, and idle-world saves are byte-stable. This changes their wire format, so each class bumps its serialization version with a `MigrateFrom` that replays the old delta-time read through the migration schema (the new `vN.json` files carry `@AnchoredTimer`; the old ones keep `@TimerDrift`, which the generator reads forever). The 2 wall-clock timers (Aquarium, FountainOfLife) keep their exact format via `wallClock: true` — no bump. Restart methods drop their `TimeSpan.MinValue` sentinel checks: v4 invokes them **only when a timer was actually running at save**. 3. **Linkage → field-side declarations** — 175 conversions across 25 files: `[SerializableFieldSaveFlag(order)]`/`[SerializableFieldDefault(order)]` become `[SaveFlag(nameof(...), nameof(...))]` on the field, and `[SerializableFieldChanged(order)]` becomes the `fieldChanged:` argument of `[SerializableField]`. **Wire-neutral: zero migration schemas changed.** ## Verification - Solution builds with **0 errors, 0 warnings**; all three 4.0.0 packages verified indexed on nuget.org (no local feed needed). - **835 + 708 tests green.** - Generated output inspected: old-version content structs replay `ReadDeltaTime` (e.g. `V3Content.DecayTimerNext = reader.ReadDeltaTime()`), current versions write/read anchored time with the gated restart, and the wall-clock classes emit byte-identical `Write`/`ReadDateTime` framing. - Schema tool run is committed (CI's `git diff --exit-code` schema check passes): exactly the 8 expected new `vN.json` files, nothing else touched. - The conversion was scripted with a class-scoped resolver (order → same-class `[SerializableField(order)]`/`[SerializableProperty(order)]`); it planned 175/175 with zero ambiguities before applying. ## Notes - New `MigrateFrom`s use the content structs' provided `XxxDelay` property, matching the pre-existing idiom in Corpse's and TreasureMapChest's older migrations. - Follow-up candidate (separate PR, wire-neutral, any time): fold the ~150 eligible hand-written `[SerializableProperty]` setters (clamps, post-change side effects) down to `[SerializableField]` with `allowFieldChange`/`fieldChanged` hooks. --- .config/dotnet-tools.json | 2 +- Projects/Server/Items/Container.cs | 11 +- Projects/Server/Mobiles/Mods/ResistanceMod.cs | 6 +- Projects/Server/Mobiles/Mods/SkillMod.cs | 14 +- Projects/Server/Server.csproj | 4 +- .../Engines/Bulk Orders/Books/BOBFilter.cs | 8 +- .../CannedEvil/ChampionTitleContext.cs | 18 +-- .../Engines/ConPVP/Games/BombingRun.cs | 3 +- .../Engines/ConPVP/Games/KingOfTheHill.cs | 3 +- .../UOContent/Engines/Plants/PlantItem.cs | 12 +- .../UOContent/Engines/Plants/PlantSystem.cs | 40 +++-- .../UOContent/Engines/Spawners/BaseSpawner.cs | 23 ++- .../UOContent/Engines/Spawners/Spawner.cs | 4 +- .../Engines/Virtues/VirtueContext.cs | 26 ++-- Projects/UOContent/Items/Aquarium/Aquarium.cs | 2 +- Projects/UOContent/Items/Armor/BaseArmor.cs | 60 +++----- .../Items/Armor/Glasses/ElvenGlasses.cs | 3 +- Projects/UOContent/Items/Books/BaseBook.cs | 11 +- .../UOContent/Items/Clothing/BaseClothing.cs | 24 ++- .../UOContent/Items/Clothing/OuterTorso.cs | 13 +- Projects/UOContent/Items/Clothing/Shoes.cs | 3 +- .../Fillable Containers/FillableContainer.cs | 15 +- .../Items/Containers/MarkContainer.cs | 18 ++- .../Items/Containers/TreasureMapChest.cs | 19 ++- Projects/UOContent/Items/Lights/BaseLight.cs | 18 ++- .../UOContent/Items/Misc/Corpses/Corpse.cs | 30 +++- .../Items/Misc/Corpses/DecayedCorpse.cs | 13 +- .../UOContent/Items/Quivers/BaseQuiver.cs | 16 +- .../Items/Skill Items/Magical/Runebook.cs | 8 +- .../8th Anniversary Items/FountainOfLife.cs | 2 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 35 ++--- .../UOContent/Items/Weapons/BaseWeapon.cs | 78 ++++------ .../Migrations/Server.Items.BaseLight.v2.json | 43 ++++++ .../Migrations/Server.Items.Corpse.v18.json | 137 ++++++++++++++++++ .../Migrations/Server.Items.DeathRobe.v4.json | 14 ++ .../Server.Items.DecayedCorpse.v3.json | 14 ++ .../Server.Items.FillableContainer.v3.json | 19 +++ .../Server.Items.MarkContainer.v1.json | 46 ++++++ .../Server.Items.TreasureMapChest.v4.json | 57 ++++++++ .../Server.Mobiles.BaseEscortable.v3.json | 43 ++++++ .../Mobiles/Animals/Mounts/Ethereals.cs | 8 +- .../Mobiles/Townfolk/BaseEscortable.cs | 18 ++- Projects/UOContent/UOContent.csproj | 4 +- 43 files changed, 667 insertions(+), 278 deletions(-) create mode 100644 Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Items.Corpse.v18.json create mode 100644 Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json create mode 100644 Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json create mode 100644 Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json create mode 100644 Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8b18d54e8..f9f5fbe15 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "3.0.0", + "version": "4.0.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index f139da24b..06c676b25 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -44,10 +44,10 @@ public partial class Container : Item internal int _version; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLiftOverride))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _liftOverride; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLiftOverride() => _liftOverride; public Container(int itemID) : base(itemID) @@ -84,6 +84,7 @@ public partial class Container : Item [EncodedInt] [SerializableProperty(0)] + [SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { @@ -96,14 +97,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; - [SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; [EncodedInt] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int GumpID { @@ -115,14 +115,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeGumpId() => _gumpID != -1; - [SerializableFieldDefault(1)] private int GumpIDDefaultValue() => -1; [EncodedInt] [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DropSound { @@ -134,10 +133,8 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDropSound() => _dropSound != -1; - [SerializableFieldDefault(2)] private int DropSoundDefaultValue() => -1; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/Server/Mobiles/Mods/ResistanceMod.cs b/Projects/Server/Mobiles/Mods/ResistanceMod.cs index bd569f20e..a5423e039 100644 --- a/Projects/Server/Mobiles/Mods/ResistanceMod.cs +++ b/Projects/Server/Mobiles/Mods/ResistanceMod.cs @@ -21,17 +21,15 @@ namespace Server; [SerializationGenerator(0)] public partial class ResistanceMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] private ResistanceType _type; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] private int _offset; - [SerializableFieldChanged(1)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances(); diff --git a/Projects/Server/Mobiles/Mods/SkillMod.cs b/Projects/Server/Mobiles/Mods/SkillMod.cs index a78ec5ce6..cfe96c0c4 100644 --- a/Projects/Server/Mobiles/Mods/SkillMod.cs +++ b/Projects/Server/Mobiles/Mods/SkillMod.cs @@ -21,33 +21,29 @@ namespace Server; [SerializationGenerator(0)] public abstract partial class SkillMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] private bool _obeyCap; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); + private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnSkillChanged))] private SkillName _skill; - [SerializableFieldChanged(1)] private void OnSkillChanged(SkillName oldValue, SkillName newValue) { Owner?.Skills[newValue]?.Update(); Owner?.Skills[oldValue]?.Update(); } - [SerializableField(2)] + [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] private bool _relative; - [SerializableFieldChanged(2)] private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(3)] + [SerializableField(3, fieldChanged: nameof(OnValueChanged))] private double _value; - [SerializableFieldChanged(3)] private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update(); public SkillMod(Mobile owner) : base(owner) diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index 522701b78..fd69557b7 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -39,8 +39,8 @@ - - + + diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index 962c3d8e3..f63e4f8d4 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders; public partial class BOBFilter { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeType))] private int _type; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeType() => _type != 0; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeQuality))] private int _quality; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeQuality() => _quality != 0; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMaterial))] private int _material; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeMaterial() => _material != 0; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeQuantity))] private int _quantity; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuantity() => _quantity != 0; private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index bfd00c0bf..644e2b965 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -51,9 +51,9 @@ public partial class ChampionTitleContext } [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeAbyss))] private ChampionTitle _abyss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAbyss() => _abyss != null; [CommandProperty(AccessLevel.GameMaster)] @@ -71,9 +71,9 @@ public partial class ChampionTitleContext } [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeArachnid))] private ChampionTitle _arachnid; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeArachnid() => _arachnid != null; [CommandProperty(AccessLevel.GameMaster)] @@ -91,9 +91,9 @@ public partial class ChampionTitleContext } [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeColdBlood))] private ChampionTitle _coldBlood; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeColdBlood() => _coldBlood != null; [CommandProperty(AccessLevel.GameMaster)] @@ -111,9 +111,9 @@ public partial class ChampionTitleContext } [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeForestLord))] private ChampionTitle _forestLord; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeForestLord() => _forestLord != null; [CommandProperty(AccessLevel.GameMaster)] @@ -131,9 +131,9 @@ public partial class ChampionTitleContext } [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeVerminHorde))] private ChampionTitle _verminHorde; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeVerminHorde() => _verminHorde != null; [CommandProperty(AccessLevel.GameMaster)] @@ -151,9 +151,9 @@ public partial class ChampionTitleContext } [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeUnholyTerror))] private ChampionTitle _unholyTerror; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeUnholyTerror() => _unholyTerror != null; [CommandProperty(AccessLevel.GameMaster)] @@ -171,9 +171,9 @@ public partial class ChampionTitleContext } [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSleepingDragon))] private ChampionTitle _sleepingDragon; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null; [CommandProperty(AccessLevel.GameMaster)] @@ -191,9 +191,9 @@ public partial class ChampionTitleContext } [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeCorrupt))] private ChampionTitle _corrupt; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeCorrupt() => _corrupt != null; [CommandProperty(AccessLevel.GameMaster)] @@ -211,9 +211,9 @@ public partial class ChampionTitleContext } [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeGlade))] private ChampionTitle _glade; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeGlade() => _glade != null; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index f32168cbf..6ab4fffea 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -830,10 +830,9 @@ public partial class BRBomb : Item [SerializationGenerator(0, false)] public partial class BRGoal : BaseAddon { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnNorthChanged))] private bool _north; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnNorthChanged(bool oldValue, bool newValue) => Remake(); diff --git a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs index 6f73a0949..6aec6f328 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item public partial class KHBoard : Item { [SerializedCommandProperty(AccessLevel.GameMaster)] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnControllerChanged))] private KHController _controller; - [SerializableFieldChanged(0)] private void OnControllerChanged(KHController oldValue, KHController newValue) { oldValue?.RemoveBoard(this); diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 60451f53b..09c45fbbd 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable [SerializedIgnoreDupe] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeSecureLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SecureLevel _level; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeSecureLevel() => (int)_level != 0; [SerializedIgnoreDupe] [SerializableField(5, setter: "private")] + [SaveFlag(nameof(ShouldSerializePlantSystem))] private PlantSystem _plantSystem; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant; // For clients older than 7.0.12.0 @@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializePlantStatus))] public PlantStatus PlantStatus { get => _plantStatus; @@ -120,10 +121,10 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializePlantType))] [CommandProperty(AccessLevel.GameMaster)] public PlantType PlantType { @@ -135,10 +136,10 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePlantType() => (int)_plantType != 0; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializePlantHue))] [CommandProperty(AccessLevel.GameMaster)] public PlantHue PlantHue { @@ -150,10 +151,10 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeShowType))] [CommandProperty(AccessLevel.GameMaster)] public bool ShowType { @@ -166,7 +167,6 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeShowType() => _showType; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 29b9f48be..14b071317 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -33,24 +33,24 @@ namespace Server.Engines.Plants private PlantItem _plant; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeFertileDirt))] private bool _fertileDirt; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeFertileDirt() => _fertileDirt; [SerializableField(1)] private DateTime _nextGrowth; [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeGrowthIndicator))] private PlantGrowthIndicator _growthIndicator; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializePollinated))] private bool _pollinated; - [SerializableFieldSaveFlag(13)] private bool ShouldSerializePollinated() => _pollinated; public PlantSystem(PlantItem plant) @@ -98,6 +98,7 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializeWater))] public int Water { get => _water; @@ -109,10 +110,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWater() => _water != 0; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeHits))] public int Hits { get => _hits; @@ -135,7 +136,6 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHits() => _hits != 0; public int MaxHits => 10 + (int)Plant.PlantStatus * 2; @@ -150,6 +150,7 @@ namespace Server.Engines.Plants }; [SerializableProperty(5)] + [SaveFlag(nameof(ShouldSerializeInfestation))] public int Infestation { get => _infestation; @@ -160,10 +161,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeInfestation() => _infestation != 0; [SerializableProperty(6)] + [SaveFlag(nameof(ShouldSerializeFungus))] public int Fungus { get => _fungus; @@ -174,10 +175,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeFungus() => _fungus != 0; [SerializableProperty(7)] + [SaveFlag(nameof(ShouldSerializePoison))] public int Poison { get => _poison; @@ -188,10 +189,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(7)] private bool ShouldSerializePoison() => _poison != 0; [SerializableProperty(8)] + [SaveFlag(nameof(ShouldSerializeDisease))] public int Disease { get => _disease; @@ -202,12 +203,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; [SerializableProperty(9)] + [SaveFlag(nameof(ShouldSerializePoisonPotion))] public int PoisonPotion { get => _poisonPotion; @@ -218,12 +219,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; [SerializableProperty(10)] + [SaveFlag(nameof(ShouldSerializeCurePotion))] public int CurePotion { get => _curePotion; @@ -234,12 +235,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeHealPotion))] public int HealPotion { get => _healPotion; @@ -250,12 +251,12 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeStrengthPotion))] public int StrengthPotion { get => _strengthPotion; @@ -266,7 +267,6 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2; @@ -274,6 +274,7 @@ namespace Server.Engines.Plants public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeSeedType))] public PlantType SeedType { get => Pollinated ? _seedType : Plant.PlantType; @@ -284,10 +285,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeSeedType() => _pollinated; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeSeedHue))] public PlantHue SeedHue { get => Pollinated ? _seedHue : Plant.PlantHue; @@ -298,53 +299,50 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeSeedHue() => _pollinated; [SerializableProperty(16)] + [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] public int AvailableSeeds { get => _availableSeeds; set => _availableSeeds = Math.Max(value, 0); } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; [SerializableProperty(17)] + [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] public int LeftSeeds { get => _leftSeeds; set => _leftSeeds = Math.Max(value, 0); } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; - [SerializableFieldDefault(17)] private int LeftSeedsDefaultValue() => 8; [SerializableProperty(18)] + [SaveFlag(nameof(ShouldSerializeAvailableResources))] public int AvailableResources { get => _availableResources; set => _availableResources = Math.Max(value, 0); } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeAvailableResources() => _availableResources != 0; [SerializableProperty(19)] + [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] public int LeftResources { get => _leftResources; set => _leftResources = Math.Max(value, 0); } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeLeftResources() => _leftResources != 8; - [SerializableFieldDefault(19)] private int LeftResourcesDefaultValue() => 8; public void Reset(bool potions) diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 7ceeb00f6..a556d6e3f 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private Guid _guid; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; @@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner private int _walkingRange = -1; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeWayPoint() => _wayPoint != null; [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeGroup() => _group; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay; - [SerializableFieldDefault(6)] private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay; - [SerializableFieldDefault(7)] private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner /// If true, the home location of the spawn is the location where it spawned /// If false, the home location of the spawn is the location of the spawner /// - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeEnd() => _end != default; [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; /// /// Controls how spawn position optimization is handled. /// - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner /// /// Maximum number of random position attempts before engaging optimization. /// - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts; - [SerializableFieldDefault(14)] private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 7c07d2681..db25cab55 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner /// When true, enables proactive spiral scanning to find valid spawn positions. /// Only relevant when SpawnPositionMode is Automatic or Enabled. /// - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeUseSpiralScan() => _useSpiralScan; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeUseSpiralScan))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _useSpiralScan; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeSpawnBounds))] [CommandProperty(AccessLevel.Developer)] public override Rectangle3D SpawnBounds { diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 3b26b5294..490002f43 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -10,97 +10,97 @@ public partial class VirtueContext { [DeltaDateTime] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeGain; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); [DeltaDateTime] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeLoss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this); [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAvailableResurrects))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _availableResurrects; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; [DeltaDateTime] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastJusticeLoss; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); [DeltaDateTime] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastCompassionLoss; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); [DeltaDateTime] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextCompassionDay; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now; [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCompassionGains))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _compassionGains; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCompassionGains() => _compassionGains > 0; [DeltaDateTime] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastValorLoss; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); [DeltaDateTime] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _lastHonorUse; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this); [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeHonorActive))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private bool _honorActive; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHonorActive() => _honorActive; [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeJusticeProtection))] private PlayerMobile _justiceProtection; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeJusticeStatus))] private JusticeProtectorStatus _justiceStatus; - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(12, setter: "private")] + [SaveFlag(nameof(ShouldSerializeValues))] private int[] _values; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeValues() { if (_values == null) diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 749c677c2..054fea588 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -31,9 +31,9 @@ namespace Server.Items private bool m_EvaluateDay; [SerializableField(0, setter: "private")] + [DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; - [DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 6f91f3a71..8f87626d7 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -18,95 +18,92 @@ namespace Server.Items { [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeArmorAttributes), nameof(ArmorAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _armorAttributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeArmorAttributes() => !_armorAttributes.IsEmpty; - [SerializableFieldDefault(1)] private AosArmorAttributes ArmorAttributesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializePhysicalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalBonus; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePhysicalBonus() => _physicalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeFireBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireBonus; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeFireBonus() => _fireBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeColdBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldBonus; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeColdBonus() => _coldBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializePoisonBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonBonus; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePoisonBonus() => _poisonBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeEnergyBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyBonus; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeEnergyBonus() => _energyBonus != 0; [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeIdentified() => _identified; [EncodedInt] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeResource() => _resource != DefaultResource; // Field 15 @@ -135,13 +132,12 @@ namespace Server.Items [SerializedIgnoreDupe] [SerializableField(23, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(23)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); private FactionItem m_FactionState; @@ -190,6 +186,7 @@ namespace Server.Items public virtual int OldIntReq => 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public ArmorQuality Quality { @@ -202,13 +199,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular; - [SerializableFieldDefault(11)] private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDurability))] [CommandProperty(AccessLevel.GameMaster)] public ArmorDurabilityLevel Durability { @@ -221,10 +217,10 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; [SerializableProperty(13)] + [SaveFlag(nameof(ShouldSerializeProtectionLevel))] [CommandProperty(AccessLevel.GameMaster)] public ArmorProtectionLevel ProtectionLevel { @@ -244,10 +240,10 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -273,11 +269,11 @@ namespace Server.Items } } - [SerializableFieldDefault(14)] private CraftResource ResourceDefaultValue() => DefaultResource; [EncodedInt] [SerializableProperty(15, useField: nameof(_armorBase))] + [SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int BaseArmorRating { @@ -290,10 +286,8 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeArmorBase() => _armorBase != -1; - [SerializableFieldDefault(15)] private int ArmorBaseDefaultValue() => -1; public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar; @@ -343,6 +337,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(16, useField: nameof(_strBonus))] + [SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrBonus { @@ -355,14 +350,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeStrBonus() => _strBonus != -1; - [SerializableFieldDefault(16)] private int StrBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(17, useField: nameof(_dexBonus))] + [SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexBonus { @@ -375,14 +369,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeDexBonus() => _dexBonus != -1; - [SerializableFieldDefault(17)] private int DexBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(18, useField: nameof(_intBonus))] + [SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntBonus { @@ -395,14 +388,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeIntBonus() => _intBonus != -1; - [SerializableFieldDefault(18)] private int IntBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(19, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -415,14 +407,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeStrReq() => _strReq != -1; - [SerializableFieldDefault(19)] private int StrReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(20, useField: nameof(_dexReq))] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -435,14 +426,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeDexReq() => _dexReq != -1; - [SerializableFieldDefault(20)] private int DexReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(21, useField: nameof(_intReq))] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -455,13 +445,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeIntReq() => _intReq != -1; - [SerializableFieldDefault(21)] private int IntReqDefaultValue() => -1; [SerializableProperty(22, useField: nameof(_meditate))] + [SaveFlag(nameof(ShouldSerializeMeditationAllowance))] [CommandProperty(AccessLevel.GameMaster)] public AMA MeditationAllowance { @@ -473,7 +462,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All; public virtual double ArmorScalar @@ -689,6 +677,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(9)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -716,7 +705,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; diff --git a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs index 4b0fb791f..e44967f0b 100644 --- a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs +++ b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs @@ -31,13 +31,12 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(0)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); public override void AppendChildNameProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index 47f368754..3d9b9f7b8 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -19,41 +19,38 @@ namespace Server.Items [InternString] [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _title; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeTitle() => _title != DefaultContent?.Title; - [SerializableFieldDefault(1)] private string TitleDefaultValue() => DefaultContent?.Title; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _author; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author; - [SerializableFieldDefault(2)] private string AuthorDefaultValue() => DefaultContent?.Author; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeWritable))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _writable; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWritable() => _writable; [SerializedIgnoreDupe] [SerializableField(4, setter: "protected")] + [SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))] private BookPageInfo[] _pages; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true; - [SerializableFieldDefault(4)] private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty(); [Constructible] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 8246c8c46..7d106e7c3 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -26,76 +26,71 @@ namespace Server.Items public abstract partial class BaseClothing : Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem { - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeResource() => _resource != DefaultResource; [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(1)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeClothingAttributes), nameof(ClothingAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _clothingAttributes; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeClothingAttributes() => !_clothingAttributes.IsEmpty; - [SerializableFieldDefault(2)] private AosArmorAttributes ClothingAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(3, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(3)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(4, setter: "private")] + [SaveFlag(nameof(ShouldSerializeResistances), nameof(ResistancesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _resistances; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeResistances() => !_resistances.IsEmpty; - [SerializableFieldDefault(4)] private AosElementAttributes ResistancesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeQuality))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; // Field 9 @@ -119,6 +114,7 @@ namespace Server.Items } [SerializableProperty(0)] + [SaveFlag(nameof(ShouldSerializeResource))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -133,6 +129,7 @@ namespace Server.Items } [SerializableProperty(9, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -145,7 +142,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -299,6 +295,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(6)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -324,7 +321,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index 4fda45bb5..ea2585ad0 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -37,21 +37,22 @@ namespace Server.Items public override double DefaultWeight => 3.0; } - [SerializationGenerator(3, false)] + [SerializationGenerator(4, false)] public partial class DeathRobe : Robe { private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0); - [TimerDrift] [SerializableField(0)] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] - private void DeserializeDecayTimer(TimeSpan delay) + private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + + private void MigrateFrom(V3Content content) { - if (delay != TimeSpan.MinValue) + if (content.DecayTimerDelay != TimeSpan.MinValue) { - BeginDecay(delay); + DeserializeDecayTimer(content.DecayTimerDelay); } } diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index 4d80b4699..b82ae2654 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -54,11 +54,10 @@ namespace Server.Items { [EncodedInt] [InvalidateProperties] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _curArcaneCharges; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update(); diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index a9da67dbb..da4d8b77f 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -3,19 +3,22 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public abstract partial class FillableContainer : LockableContainer { - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeRespawnTimer))] private Timer _respawnTimer; - [DeserializeTimerField(1)] - private void DeserializeRespawnTimer(TimeSpan delay) + private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn); + + private void MigrateFrom(V2Content content) { - if (delay > TimeSpan.MinValue) + _contentType = content.ContentType; + + if (content.RespawnTimerDelay != TimeSpan.MinValue) { - _respawnTimer = Timer.DelayCall(delay, Respawn); + DeserializeRespawnTimer(content.RespawnTimerDelay); } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index fa3f7e440..6a7e073f3 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -3,14 +3,13 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class MarkContainer : LockableContainer { - [TimerDrift] [SerializableField(1, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeRelockTimer))] private InternalTimer _relockTimer; - [DeserializeTimerField(1)] private void DeserializeRelockTimer(TimeSpan delay) { if (!Locked && _autoLock) @@ -19,6 +18,19 @@ public partial class MarkContainer : LockableContainer } } + private void MigrateFrom(V0Content content) + { + _autoLock = content.AutoLock; + _targetMap = content.TargetMap; + _target = content.Target; + _description = content.Description; + + if (content.RelockTimerDelay != TimeSpan.MinValue) + { + DeserializeRelockTimer(content.RelockTimerDelay); + } + } + [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Map _targetMap; diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index e0486182d..a1aafa0b3 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,7 +9,7 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] @@ -29,12 +29,11 @@ public partial class TreasureMapChest : LockableContainer [SerializedCommandProperty(AccessLevel.GameMaster)] private int _level; - [TimerDrift] [SerializableField(4)] [SerializedCommandProperty(AccessLevel.GameMaster)] + [DeserializeTimer(nameof(DeserializeExpireTimer))] private Timer _expireTimer; - [DeserializeTimerField(4)] private void DeserializeExpireTimer(TimeSpan delay) { if (!_temporary) @@ -43,6 +42,20 @@ public partial class TreasureMapChest : LockableContainer } } + private void MigrateFrom(V3Content content) + { + _guardians = content.Guardians; + _temporary = content.Temporary; + _owner = content.Owner; + _level = content.Level; + _lifted = content.Lifted; + + if (content.ExpireTimerDelay != TimeSpan.MinValue) + { + DeserializeExpireTimer(content.ExpireTimerDelay); + } + } + [Tidy] [CanBeNull] [SerializableField(5, setter: "private")] diff --git a/Projects/UOContent/Items/Lights/BaseLight.cs b/Projects/UOContent/Items/Lights/BaseLight.cs index d3bff4af5..4bd63ba63 100644 --- a/Projects/UOContent/Items/Lights/BaseLight.cs +++ b/Projects/UOContent/Items/Lights/BaseLight.cs @@ -3,7 +3,7 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseLight : Item { public static readonly bool Burnout = false; @@ -16,11 +16,10 @@ public abstract partial class BaseLight : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _protected; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeTimer))] private Timer _burnTimer; - [DeserializeTimerField(4)] private void DeserializeTimer(TimeSpan delay) { if (_burning && _duration != TimeSpan.Zero) @@ -29,6 +28,19 @@ public abstract partial class BaseLight : Item } } + private void MigrateFrom(V1Content content) + { + _burntOut = content.BurntOut; + _burning = content.Burning; + _duration = content.Duration; + _protected = content.Protected; + + if (content.BurnTimerDelay != TimeSpan.MinValue) + { + DeserializeTimer(content.BurnTimerDelay); + } + } + [Constructible] public BaseLight(int itemID) : base(itemID) { diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 1ee04170c..59db3dc98 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(17, false)] +[SerializationGenerator(18, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -114,13 +114,37 @@ public partial class Corpse : Container, ICarvable [SerializableField(3, getter: "private", setter: "private")] private Dictionary _restoreTable; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(4)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + private void MigrateFrom(V17Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + [SerializableField(5, setter: "private")] private HashSet _looters; diff --git a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs index ead812e2f..17267a6cc 100644 --- a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs @@ -3,18 +3,25 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class DecayedCorpse : Container { private static TimeSpan _defaultDecayTime = TimeSpan.FromMinutes(7.0); - [TimerDrift] [SerializableField(0, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + private void MigrateFrom(V2Content content) + { + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + public DecayedCorpse(string name) : base(Utility.Random(0xECA, 9)) { Movable = false; diff --git a/Projects/UOContent/Items/Quivers/BaseQuiver.cs b/Projects/UOContent/Items/Quivers/BaseQuiver.cs index 427871f16..70824c9f6 100644 --- a/Projects/UOContent/Items/Quivers/BaseQuiver.cs +++ b/Projects/UOContent/Items/Quivers/BaseQuiver.cs @@ -11,64 +11,62 @@ public partial class BaseQuiver : Container, ICraftable, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLowerAmmoCost))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _lowerAmmoCost; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLowerAmmoCost() => _lowerAmmoCost != 0; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeWeightReduction))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _weightReduction; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeWeightReduction() => _weightReduction != 0; [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDamageIncrease))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageIncrease; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeDamageIncrease() => _damageIncrease != 0; [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - [SerializableFieldDefault(5)] private ClothingQuality QualityDefaultValue() => ClothingQuality.Regular; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCapacity))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _capacity; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCapacity() => _capacity != 0; public BaseQuiver(int itemID = 0x2FB7) : base(itemID) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs index 0a5a0f308..f9ecdfa22 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs @@ -371,27 +371,27 @@ public partial class RunebookEntry private Runebook _runebook; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeHouse))] private BaseHouse _house; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeHouse() => _house?.Deleted == false; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLocation))] private Point3D _location; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeLocation() => _house?.Deleted != false; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMap))] private Map _map; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeMap() => _house?.Deleted != false; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDesc))] private string _description; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeDesc() => _house?.Deleted != false; public RunebookEntry( diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index 3bdfd00fa..b66056831 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -31,6 +31,7 @@ public partial class FountainOfLife : BaseAddonContainer public const int MaxCharges = 10; [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeTimer), wallClock: true)] private Timer _timer; [Constructible] @@ -39,7 +40,6 @@ public partial class FountainOfLife : BaseAddonContainer _charges = charges; } - [DeserializeTimerField(1)] private void DeserializeTimer(TimeSpan delay) { _timer = Timer.DelayCall(Utility.Max(delay, TimeSpan.Zero), RechargeTime, Recharge); diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index 632587523..3853d4594 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -128,136 +128,131 @@ public partial class BaseTalisman : Item, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(1)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeProtection), nameof(ProtectionDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _protection; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeProtection() => !_protection.IsEmpty; - [SerializableFieldDefault(2)] private TalismanAttribute ProtectionDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeKiller), nameof(KillerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _killer; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeKiller() => !_killer.IsEmpty; - [SerializableFieldDefault(3)] private TalismanAttribute KillerDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeSummoner), nameof(SummonerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _summoner; - [SerializableFieldSaveFlag(4)] public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; - [SerializableFieldDefault(4)] private TalismanAttribute SummonerDefaultValue() => new(); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeRemoval))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanRemoval _removal; - [SerializableFieldSaveFlag(5)] public bool ShouldSerializeRemoval() => _removal != TalismanRemoval.None; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSkill))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SkillName _skill; - [SerializableFieldSaveFlag(6)] public bool ShouldSerializeSkill() => (int)_skill != 0; [EncodedInt] [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSuccessBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _successBonus; - [SerializableFieldSaveFlag(7)] public bool ShouldSerializeSuccessBonus() => _successBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeExceptionalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _exceptionalBonus; - [SerializableFieldSaveFlag(8)] public bool ShouldSerializeExceptionalBonus() => _exceptionalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeMaxCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxCharges; - [SerializableFieldSaveFlag(9)] public bool ShouldSerializeMaxCharges() => _maxCharges != 0; [EncodedInt] [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeMaxChargeTime))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxChargeTime; - [SerializableFieldSaveFlag(11)] public bool ShouldSerializeMaxChargeTime() => _maxChargeTime != 0; [EncodedInt] [InvalidateProperties] [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeChargeTime))] private int _chargeTime; - [SerializableFieldSaveFlag(12)] public bool ShouldSerializeChargeTime() => _chargeTime != 0; [InvalidateProperties] [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeBlessed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _blessed; - [SerializableFieldSaveFlag(13)] public bool ShouldSerializeBlessed() => _blessed; [InvalidateProperties] [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanSlayerName _slayer; - [SerializableFieldSaveFlag(14)] public bool ShouldSerializeSlayer() => _slayer != TalismanSlayerName.None; private BaseCreature _creature; @@ -285,6 +280,7 @@ public partial class BaseTalisman : Item, IAosItem public virtual bool ForceShowName => false; // used to override default summoner/removal name [SerializableProperty(10)] + [SaveFlag(nameof(ShouldSerializeCharges))] [CommandProperty(AccessLevel.GameMaster)] public int Charges { @@ -303,7 +299,6 @@ public partial class BaseTalisman : Item, IAosItem } } - [SerializableFieldSaveFlag(10)] public bool ShouldSerializeCharges() => _charges != 0; public static void Configure() diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 1f03daf63..d48824670 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -56,130 +56,126 @@ public abstract partial class BaseWeapon [InvalidateProperties] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeDamageLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private WeaponDamageLevel _damageLevel; - [SerializableFieldSaveFlag(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeDamageLevel() => _damageLevel != WeaponDamageLevel.Regular; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer; - [SerializableFieldSaveFlag(6)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer() => _slayer != SlayerName.None; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializePoison))] [SerializedCommandProperty(AccessLevel.GameMaster)] private Poison _poison; - [SerializableFieldSaveFlag(7)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoison() => _poison != null; [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePoisonCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonCharges; - [SerializableFieldSaveFlag(8)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoisonCharges() => _poisonCharges > 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(9)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(10)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeIdentified() => _identified; [SerializedIgnoreDupe] [SerializableField(24, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(24)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(24)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(25, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(25)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(25)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(26, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(26)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(26)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeSlayer2))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; - [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] [SerializableField(28, setter: "private")] + [SaveFlag(nameof(ShouldSerializeElementAttributes), nameof(AosElementAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; - [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; - [SerializableFieldDefault(28)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeEngravedText))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; - [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -286,6 +282,7 @@ public abstract partial class BaseWeapon public bool Consecrated { get; set; } [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeWeaponAccuracy))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAccuracyLevel AccuracyLevel { @@ -321,10 +318,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeWeaponAccuracy() => _accuracyLevel != WeaponAccuracyLevel.Regular; [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDurabilityLevel))] [CommandProperty(AccessLevel.GameMaster)] public WeaponDurabilityLevel DurabilityLevel { @@ -339,10 +336,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDurabilityLevel() => _durabilityLevel != WeaponDurabilityLevel.Regular; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponQuality Quality { @@ -357,13 +354,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuality() => _quality != WeaponQuality.Regular; - [SerializableFieldDefault(3)] private WeaponQuality QualityDefaultValue() => WeaponQuality.Regular; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -387,10 +383,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHitPoints() => _hitPoints > 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -403,13 +399,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeStrReq() => _strRequirement != -1; - [SerializableFieldDefault(11)] private int StrReqDefaultValue() => -1; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -422,13 +417,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDexReq() => _dexRequirement != -1; - [SerializableFieldDefault(12)] private int DexReqDefaultValue() => -1; [SerializableProperty(13)] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -441,13 +435,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeIntReq() => _intRequirement != -1; - [SerializableFieldDefault(13)] private int IntReqDefaultValue() => -1; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeMinDamage), nameof(MinDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MinDamage { @@ -460,13 +453,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMinDamage() => _minDamage != -1; - [SerializableFieldDefault(14)] private int MinDamageDefaultValue() => -1; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeMaxDamage), nameof(MaxDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxDamage { @@ -479,13 +471,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeMaxDamage() => _maxDamage != -1; - [SerializableFieldDefault(15)] private int MaxDamageDefaultValue() => -1; [SerializableProperty(16)] + [SaveFlag(nameof(ShouldSerializeHitSound), nameof(HitSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int HitSound { @@ -497,13 +488,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeHitSound() => _hitSound != -1; - [SerializableFieldDefault(16)] private int HitSoundDefaultValue() => -1; [SerializableProperty(17)] + [SaveFlag(nameof(ShouldSerializeMissSound), nameof(MissSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MissSound { @@ -515,13 +505,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeMissSound() => _missSound != -1; - [SerializableFieldDefault(17)] private int MissSoundDefaultValue() => -1; [SerializableProperty(18)] + [SaveFlag(nameof(ShouldSerializeSpeed), nameof(SpeedDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public float Speed { @@ -552,13 +541,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeSpeed() => _speed != -1; - [SerializableFieldDefault(18)] private float SpeedDefaultValue() => -1; [SerializableProperty(19)] + [SaveFlag(nameof(ShouldSerializeMaxRange), nameof(MaxRangeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxRange { @@ -571,13 +559,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeMaxRange() => _maxRange != -1; - [SerializableFieldDefault(19)] private int MaxRangeDefaultValue() => -1; [SerializableProperty(20)] + [SaveFlag(nameof(ShouldSerializeSkill), nameof(SkillNameDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public SkillName Skill { @@ -590,13 +577,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeSkill() => _skill != (SkillName)(-1); - [SerializableFieldDefault(20)] private SkillName SkillNameDefaultValue() => (SkillName)(-1); [SerializableProperty(21)] + [SaveFlag(nameof(ShouldSerializeType), nameof(TypeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponType Type { @@ -608,13 +594,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeType() => _type != (WeaponType)(-1); - [SerializableFieldDefault(21)] private WeaponType TypeDefaultValue() => (WeaponType)(-1); [SerializableProperty(22)] + [SaveFlag(nameof(ShouldSerializeAnimation), nameof(AnimationDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAnimation Animation { @@ -626,13 +611,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeAnimation() => _animation != (WeaponAnimation)(-1); - [SerializableFieldDefault(22)] private WeaponAnimation AnimationDefaultValue() => (WeaponAnimation)(-1); [SerializableProperty(23)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -648,10 +632,8 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeResource() => _resource != CraftResource.Iron; - [SerializableFieldDefault(23)] private CraftResource ResourceDefaultValue() => CraftResource.Iron; public virtual int OnCraft( diff --git a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json new file mode 100644 index 000000000..c04a03f3b --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json @@ -0,0 +1,43 @@ +{ + "version": 2, + "type": "Server.Items.BaseLight", + "properties": [ + { + "name": "BurntOut", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Burning", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Duration", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Protected", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BurnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json new file mode 100644 index 000000000..452e3ba7c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json @@ -0,0 +1,137 @@ +{ + "version": 18, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "DeltaTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json new file mode 100644 index 000000000..ef81d79cd --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json @@ -0,0 +1,14 @@ +{ + "version": 4, + "type": "Server.Items.DeathRobe", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json new file mode 100644 index 000000000..adcf02ff6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json @@ -0,0 +1,14 @@ +{ + "version": 3, + "type": "Server.Items.DecayedCorpse", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json new file mode 100644 index 000000000..2384676a3 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json @@ -0,0 +1,19 @@ +{ + "version": 3, + "type": "Server.Items.FillableContainer", + "properties": [ + { + "name": "ContentType", + "type": "Server.Items.FillableContentType", + "rule": "EnumMigrationRule" + }, + { + "name": "RespawnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json new file mode 100644 index 000000000..267ca4709 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "type": "Server.Items.MarkContainer", + "properties": [ + { + "name": "AutoLock", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RelockTimer", + "type": "Server.Items.MarkContainer.InternalTimer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "TargetMap", + "type": "Server.Map", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Target", + "type": "Server.Point3D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "Description", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json new file mode 100644 index 000000000..b4d1f85d4 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json @@ -0,0 +1,57 @@ +{ + "version": 4, + "type": "Server.Items.TreasureMapChest", + "properties": [ + { + "name": "Guardians", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Temporary", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ExpireTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Lifted", + "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json new file mode 100644 index 000000000..5149f4347 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json @@ -0,0 +1,43 @@ +{ + "version": 3, + "type": "Server.Mobiles.BaseEscortable", + "properties": [ + { + "name": "DestinationString", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DeleteTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "MlQuestType", + "type": "System.Type", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MlQuestDestinationMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + }, + { + "name": "MlQuestPaymentMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index f13cebcbe..e2018ab14 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -11,19 +11,19 @@ namespace Server.Mobiles public partial class EtherealMount : Item, IMount, IMountItem, IRewardItem { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeIsDonationItem))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool _isDonationItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeIsDonationItem() => _isDonationItem; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeIsRewardItem))] [SerializedCommandProperty(AccessLevel.GameMaster)] public bool _isRewardItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeIsRewardItem() => _isRewardItem; [Constructible] @@ -87,6 +87,7 @@ namespace Server.Mobiles public virtual int EtherealHue => 0x4001; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeRider))] [CommandProperty(AccessLevel.GameMaster)] public Mobile Rider { @@ -124,11 +125,11 @@ namespace Server.Mobiles } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeRider() => _rider != null; [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(5)] + [SaveFlag(nameof(ShouldSerializeSteps))] public int Steps { get => _steps; @@ -139,7 +140,6 @@ namespace Server.Mobiles } } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeSteps() => _steps != StepsMax; public virtual int StepsMax => 3840; // Should be same as horse diff --git a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs index 462307be3..cf439a76c 100644 --- a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs +++ b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs @@ -17,7 +17,7 @@ using EDI = Server.Mobiles.EscortDestinationInfo; namespace Server.Mobiles; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class BaseEscortable : BaseCreature { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseEscortable)); @@ -158,16 +158,22 @@ public partial class BaseEscortable : BaseCreature [SerializableField(0, setter: "private")] private string _destinationString; - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeDeleteTimer))] private Timer _deleteTimer; - [DeserializeTimerField(1)] - private void DeserializeDeleteTimer(TimeSpan delay) + private void DeserializeDeleteTimer(TimeSpan delay) => Timer.DelayCall(delay, Delete); + + private void MigrateFrom(V2Content content) { - if (delay >= TimeSpan.Zero) + _destinationString = content.DestinationString; + _mlQuestType = content.MlQuestType; + _mlQuestDestinationMessage = content.MlQuestDestinationMessage; + _mlQuestPaymentMessage = content.MlQuestPaymentMessage; + + if (content.DeleteTimerDelay != TimeSpan.MinValue) { - Timer.DelayCall(delay, Delete); + DeserializeDeleteTimer(content.DeleteTimerDelay); } } diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 5c7b74214..60f28ac6f 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -50,8 +50,8 @@ - - + + From b042edcf0be1d268238490253a76902010fe7c5e Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:20:39 -0700 Subject: [PATCH 51/64] refactor: fold hand-written serializable property setters into field hooks (#2587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate. ```cs // before [SerializableProperty(1)] [CommandProperty(AccessLevel.GameMaster)] public int Charges { get => _charges; set { _charges = Math.Clamp(value, 0, MaxCharges); InvalidateProperties(); this.MarkDirty(); } } // after [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] [SerializedCommandProperty(AccessLevel.GameMaster)] [InvalidateProperties] private int _charges; private bool AllowChargesChange(ref int value) { value = Math.Clamp(value, 0, MaxCharges); return true; } ``` ## How sites were selected A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately: - **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these. - **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight). - **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field). Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`). ## Verification - Build: **0 errors, 0 warnings**. - **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it. - **835 + 708 tests green.** ## Behavioral notes (all strict improvements, called out for review) - Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes). - Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction. --- .../CannedEvil/ChampionSkullBrazier.cs | 16 +- .../Engines/CannedEvil/ChampionSpawn.cs | 57 +++-- Projects/UOContent/Engines/ConPVP/Trophy.cs | 16 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 14 +- .../UOContent/Engines/Plants/PlantItem.cs | 46 ++-- .../UOContent/Engines/Plants/PlantSystem.cs | 203 +++++++++--------- Projects/UOContent/Engines/Plants/Seed.cs | 18 +- .../Player Murder System/MurderContext.cs | 12 +- .../The Summoning/Items/SummoningAltar.cs | 14 +- .../UOContent/Engines/Spawners/BaseSpawner.cs | 30 ++- .../Treasures of Tokuno/GreaterArtifacts.cs | 33 ++- .../Treasures of Tokuno/LesserArtifacts.cs | 33 ++- .../Character Statue Maker/CharacterStatue.cs | 52 ++--- .../CharacterStatueMaker.cs | 16 +- Projects/UOContent/Items/Addons/BaseAddon.cs | 22 +- .../Items/Addons/BaseAddonContainer.cs | 22 +- .../Items/Addons/BaseAddonContainerDeed.cs | 22 +- .../Items/Addons/FlourMillEastAddon.cs | 22 +- .../Items/Addons/FlourMillSouthAddon.cs | 21 +- .../UOContent/Items/Addons/SHTeleporter.cs | 40 ++-- .../UOContent/Items/Aquarium/AquariumState.cs | 19 +- Projects/UOContent/Items/Armor/BaseArmor.cs | 25 +-- .../Items/Armor/Leather/LeafGloves.cs | 34 ++- .../Items/Armor/Leather/LeatherGloves.cs | 34 ++- .../UOContent/Items/Clothing/BaseClothing.cs | 18 +- Projects/UOContent/Items/Clothing/Cloaks.cs | 36 ++-- .../UOContent/Items/Clothing/OuterTorso.cs | 36 ++-- Projects/UOContent/Items/Clothing/Shoes.cs | 18 +- .../Items/Construction/Doors/BaseDoor.cs | 58 ++--- .../Items/Containers/MarkContainer.cs | 28 ++- .../Items/Deeds/DragonBardingDeed.cs | 17 +- Projects/UOContent/Items/Food/Beverage.cs | 32 ++- Projects/UOContent/Items/Food/Cooking.cs | 34 +-- .../Items/Games/Mahjong/MahjongGame.cs | 60 ++---- Projects/UOContent/Items/Jewels/BaseJewel.cs | 15 +- .../Items/Misc/CommunicationCrystals.cs | 22 +- Projects/UOContent/Items/Misc/MorphItem.cs | 24 ++- Projects/UOContent/Items/Misc/WarningItem.cs | 20 +- .../Items/Resources/Blacksmithing/Ingots.cs | 22 +- .../Items/Resources/Blacksmithing/Ore.cs | 22 +- .../Items/Resources/Blacksmithing/Scales.cs | 22 +- .../Items/Resources/Masonry/Granite.cs | 22 +- .../UOContent/Items/Resources/Tailor/Hides.cs | 22 +- .../Items/Resources/Tailor/Leathers.cs | 22 +- .../Skill Items/Carpenter Items/Board.cs | 22 +- .../Fishing/Misc/MessageInABottle.cs | 16 +- .../Items/Skill Items/Fishing/Misc/SOS.cs | 24 ++- .../Items/Skill Items/Lumberjack/Log.cs | 22 +- .../Skill Items/Magical/Misc/RecallRune.cs | 40 ++-- .../Items/Skill Items/Magical/Spellbook.cs | 31 +-- .../Items/Skill Items/Misc/RepairDeed.cs | 18 +- .../Musical Instruments/BaseInstrument.cs | 15 +- .../Tailor Items/Dyetubs/DyeTub.cs | 21 +- .../Items/Skill Items/Tools/BaseRunicTool.cs | 17 +- .../8th Anniversary Items/FountainOfLife.cs | 36 ++-- .../Special/Heritage Items/FruitTrees.cs | 12 +- .../Special/House Raffle/HouseRaffleStone.cs | 56 ++--- .../Items/Special/MonsterStatuette.cs | 22 +- .../Items/Special/Solen Items/BagOfSending.cs | 65 +++--- .../Special/Solen Items/BallOfSummoning.cs | 36 ++-- .../Special/Solen Items/BraceletOfBinding.cs | 36 ++-- Projects/UOContent/Items/Special/SoulStone.cs | 20 +- Projects/UOContent/Items/Suits/BaseSuit.cs | 17 +- .../UOContent/Items/Talismans/BaseTalisman.cs | 22 +- Projects/UOContent/Misc/ShardPoller.cs | 64 +++--- .../UOContent/Mobiles/Animals/Misc/Sheep.cs | 16 +- .../Mobiles/Animals/Mounts/Ethereals.cs | 64 ++---- .../Mobiles/Animals/Mounts/SwampDragon.cs | 61 +++--- .../UOContent/Mobiles/Hireables/BaseHire.cs | 19 +- .../Vendors/Barkeeper/PlayerBarkeeper.cs | 17 +- .../UOContent/Mobiles/Vendors/PlayerVendor.cs | 17 +- .../UOContent/Mobiles/Vendors/VendorItem.cs | 24 ++- Projects/UOContent/Multis/Boats/BaseBoat.cs | 32 ++- 73 files changed, 893 insertions(+), 1340 deletions(-) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs index 9ff8dc633..2632fd954 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs @@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent [SerializedCommandProperty(AccessLevel.GameMaster)] private ChampionSkullPlatform _platform; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public Item Skull + [SerializableField(2, fieldChanged: nameof(OnSkullChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Item _skull; + + private void OnSkullChanged(Item oldValue, Item newValue) { - get => _skull; - set - { - _skull = value; - this.MarkDirty(); - _platform?.Validate(); - } + _platform?.Validate(); } public override int LabelNumber => 1049489 + (int)_type; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index a26bcc0f9..d1e14c834 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -203,47 +203,38 @@ public partial class ChampionSpawn : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public int MaxLevel + [SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private int _maxLevel; + + private bool AllowMaxLevelChange(ref int value) { - get => _maxLevel; - set => _maxLevel = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(9)] - [CommandProperty(AccessLevel.GameMaster)] - public Rectangle2D SpawnArea + [SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private Rectangle2D _spawnArea; + + private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue) { - get => _spawnArea; - set - { - _spawnArea = value; - this.MarkDirty(); - InvalidateProperties(); - UpdateRegion(); - } + UpdateRegion(); } - [SerializableProperty(11)] - [CommandProperty(AccessLevel.GameMaster)] - public int Kills + [SerializableField(11, fieldChanged: nameof(OnKillsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _kills; + + private void OnKillsChanged(int oldValue, int newValue) { - get => _kills; - set + var n = _kills / (double)MaxKills; + var p = (int)(n * 100); + if (p < 90) { - _kills = value; - this.MarkDirty(); - - var n = _kills / (double)MaxKills; - var p = (int)(n * 100); - - if (p < 90) - { - SetWhiteSkullCount(p / 20); - } - - InvalidateProperties(); + SetWhiteSkullCount(p / 20); } } diff --git a/Projects/UOContent/Engines/ConPVP/Trophy.cs b/Projects/UOContent/Engines/ConPVP/Trophy.cs index fa712de33..603b52ef3 100644 --- a/Projects/UOContent/Engines/ConPVP/Trophy.cs +++ b/Projects/UOContent/Engines/ConPVP/Trophy.cs @@ -64,17 +64,13 @@ public partial class Trophy : Item UpdateStyle(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public TrophyRank Rank + [SerializableField(1, fieldChanged: nameof(OnRankChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private TrophyRank _rank; + + private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue) { - get => _rank; - set - { - _rank = value; - UpdateStyle(); - this.MarkDirty(); - } + UpdateStyle(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index 4548522b4..fbb3af7b6 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -237,16 +237,12 @@ namespace Server.Items } } - [SerializableProperty(0)] - public PuzzleChestSolution Solution + [SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] + private PuzzleChestSolution _solution; + + private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue) { - get => _solution; - set - { - _solution = value; - InitHints(); - this.MarkDirty(); - } + InitHints(); } public PuzzleChestCylinder FirstHint diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 09c45fbbd..5f66b1a7a 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -123,49 +123,35 @@ public partial class PlantItem : Item, ISecurable private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; - [SerializableProperty(2)] + [SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))] [SaveFlag(nameof(ShouldSerializePlantType))] - [CommandProperty(AccessLevel.GameMaster)] - public PlantType PlantType + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantType _plantType; + + private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue) { - get => _plantType; - set - { - _plantType = value; - Update(); - } + Update(); } private bool ShouldSerializePlantType() => (int)_plantType != 0; - [SerializableProperty(3)] + [SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))] [SaveFlag(nameof(ShouldSerializePlantHue))] - [CommandProperty(AccessLevel.GameMaster)] - public PlantHue PlantHue + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Update(); - } + Update(); } private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; - [SerializableProperty(4)] + [SerializableField(4)] [SaveFlag(nameof(ShouldSerializeShowType))] - [CommandProperty(AccessLevel.GameMaster)] - public bool ShowType - { - get => _showType; - set - { - _showType = value; - InvalidateProperties(); - this.MarkDirty(); - } - } + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _showType; private bool ShouldSerializeShowType() => _showType; diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 14b071317..4b202ef63 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -97,43 +97,40 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; - [SerializableProperty(3)] + [SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))] [SaveFlag(nameof(ShouldSerializeWater))] - public int Water + private int _water; + + private bool AllowWaterChange(ref int value) { - get => _water; - set - { - _water = Math.Clamp(value, 0, 4); - Plant.InvalidateProperties(); - MarkDirty(); - } + value = Math.Clamp(value, 0, 4); + return true; + } + + private void OnWaterChanged(int oldValue, int newValue) + { + Plant.InvalidateProperties(); } private bool ShouldSerializeWater() => _water != 0; - [SerializableProperty(4)] + [SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))] [SaveFlag(nameof(ShouldSerializeHits))] - public int Hits + private int _hits; + + private bool AllowHitsChange(ref int value) { - get => _hits; - set + value = Math.Clamp(value, 0, MaxHits); + return true; + } + + private void OnHitsChanged(int oldValue, int newValue) + { + if (_hits == 0) { - if (_hits == value) - { - return; - } - - _hits = Math.Clamp(value, 0, MaxHits); - - if (_hits == 0) - { - Plant.Die(); - } - - Plant.InvalidateProperties(); - MarkDirty(); + Plant.Die(); } + Plant.InvalidateProperties(); } private bool ShouldSerializeHits() => _hits != 0; @@ -149,122 +146,106 @@ namespace Server.Engines.Plants _ => PlantHealth.Vibrant }; - [SerializableProperty(5)] + [SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))] [SaveFlag(nameof(ShouldSerializeInfestation))] - public int Infestation + private int _infestation; + + private bool AllowInfestationChange(ref int value) { - get => _infestation; - set - { - _infestation = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeInfestation() => _infestation != 0; - [SerializableProperty(6)] + [SerializableField(6, allowFieldChange: nameof(AllowFungusChange))] [SaveFlag(nameof(ShouldSerializeFungus))] - public int Fungus + private int _fungus; + + private bool AllowFungusChange(ref int value) { - get => _fungus; - set - { - _fungus = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeFungus() => _fungus != 0; - [SerializableProperty(7)] + [SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))] [SaveFlag(nameof(ShouldSerializePoison))] - public int Poison + private int _poison; + + private bool AllowPoisonChange(ref int value) { - get => _poison; - set - { - _poison = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializePoison() => _poison != 0; - [SerializableProperty(8)] + [SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))] [SaveFlag(nameof(ShouldSerializeDisease))] - public int Disease + private int _disease; + + private bool AllowDiseaseChange(ref int value) { - get => _disease; - set - { - _disease = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; - [SerializableProperty(9)] + [SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))] [SaveFlag(nameof(ShouldSerializePoisonPotion))] - public int PoisonPotion + private int _poisonPotion; + + private bool AllowPoisonPotionChange(ref int value) { - get => _poisonPotion; - set - { - _poisonPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; - [SerializableProperty(10)] + [SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))] [SaveFlag(nameof(ShouldSerializeCurePotion))] - public int CurePotion + private int _curePotion; + + private bool AllowCurePotionChange(ref int value) { - get => _curePotion; - set - { - _curePotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; - [SerializableProperty(11)] + [SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))] [SaveFlag(nameof(ShouldSerializeHealPotion))] - public int HealPotion + private int _healPotion; + + private bool AllowHealPotionChange(ref int value) { - get => _healPotion; - set - { - _healPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; - [SerializableProperty(12)] + [SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))] [SaveFlag(nameof(ShouldSerializeStrengthPotion))] - public int StrengthPotion + private int _strengthPotion; + + private bool AllowStrengthPotionChange(ref int value) { - get => _strengthPotion; - set - { - _strengthPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; @@ -301,44 +282,52 @@ namespace Server.Engines.Plants private bool ShouldSerializeSeedHue() => _pollinated; - [SerializableProperty(16)] + [SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))] [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] - public int AvailableSeeds + private int _availableSeeds; + + private bool AllowAvailableSeedsChange(ref int value) { - get => _availableSeeds; - set => _availableSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; - [SerializableProperty(17)] + [SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))] [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] - public int LeftSeeds + private int _leftSeeds; + + private bool AllowLeftSeedsChange(ref int value) { - get => _leftSeeds; - set => _leftSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; private int LeftSeedsDefaultValue() => 8; - [SerializableProperty(18)] + [SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))] [SaveFlag(nameof(ShouldSerializeAvailableResources))] - public int AvailableResources + private int _availableResources; + + private bool AllowAvailableResourcesChange(ref int value) { - get => _availableResources; - set => _availableResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeAvailableResources() => _availableResources != 0; - [SerializableProperty(19)] + [SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))] [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] - public int LeftResources + private int _leftResources; + + private bool AllowLeftResourcesChange(ref int value) { - get => _leftResources; - set => _leftResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } private bool ShouldSerializeLeftResources() => _leftResources != 8; diff --git a/Projects/UOContent/Engines/Plants/Seed.cs b/Projects/UOContent/Engines/Plants/Seed.cs index 24a290f0b..ed6f13b7b 100644 --- a/Projects/UOContent/Engines/Plants/Seed.cs +++ b/Projects/UOContent/Engines/Plants/Seed.cs @@ -35,18 +35,14 @@ public partial class Seed : Item public override double DefaultWeight => 1.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public PlantHue PlantHue + [SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Hue = PlantHueInfo.GetInfo(value).Hue; - InvalidateProperties(); - this.MarkDirty(); - } + Hue = PlantHueInfo.GetInfo(newValue).Hue; } public override int LabelNumber => 1060810; // seed diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index cecd7bf7e..384c91c98 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -16,12 +16,14 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _longTermElapse; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int ShortTermMurders + [SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _shortTermMurders; + + private bool AllowShortTermMurdersChange(ref int value) { - get => _shortTermMurders; - set => _shortTermMurders = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } [SerializableField(3)] diff --git a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs index d5ec6f24a..4e3e5e237 100644 --- a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs +++ b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs @@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon { } - [SerializableProperty(0)] - public BoneDemon Daemon + [SerializableField(0, fieldChanged: nameof(OnDaemonChanged))] + private BoneDemon _daemon; + + private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue) { - get => _daemon; - set - { - _daemon = value; - CheckDaemon(); - this.MarkDirty(); - } + CheckDaemon(); } public void CheckDaemon() diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index a556d6e3f..8efc54b8c 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -311,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(8)] - [CommandProperty(AccessLevel.Developer)] - public int Count + [SerializableField(8, fieldChanged: nameof(OnCountChanged))] + [SerializedCommandProperty(AccessLevel.Developer)] + [InvalidateProperties] + private int _count; + + private void OnCountChanged(int oldValue, int newValue) { - get => _count; - set + if (IsFull) { - _count = value; - - if (IsFull) - { - _timer?.Stop(); - } - else if (_timer?.Running != true) - { - DoTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + _timer?.Stop(); + } + else if (_timer?.Running != true) + { + DoTimer(); } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs index 005688e1e..c42cfae4e 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs @@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public PigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PigmentType _type; + + private void OnTypeChanged(PigmentType oldValue, PigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs index 54d026df4..22c6af344 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs @@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public LesserPigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private LesserPigmentType _type; + + private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs index ff665ec53..7a62cc735 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs @@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem InvalidateHues(); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatuePose Pose + [SerializableField(1, fieldChanged: nameof(OnPoseChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatuePose _pose; + + private void OnPoseChanged(StatuePose oldValue, StatuePose newValue) { - get => _pose; - set - { - _pose = value; - InvalidatePose(); - this.MarkDirty(); - } + InvalidatePose(); } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueMaterial Material + [SerializableField(2, fieldChanged: nameof(OnMaterialChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueMaterial _material; + + private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue) { - get => _material; - set - { - _material = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs index a028b1c85..48ae30ae9 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs @@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem public override int LabelNumber => 1076173; // Character Statue Maker - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHue(); - this.MarkDirty(); - } + InvalidateHue(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Items/Addons/BaseAddon.cs b/Projects/UOContent/Items/Addons/BaseAddon.cs index 6b2abbc66..f2a662f6a 100644 --- a/Projects/UOContent/Items/Addons/BaseAddon.cs +++ b/Projects/UOContent/Items/Addons/BaseAddon.cs @@ -63,22 +63,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } Item IAddon.Deed => Deed; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs index ad655c24e..8554ad4e5 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs @@ -41,22 +41,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual bool RetainDeedHue => false; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs index c7e08e955..7d5014dee 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs @@ -23,22 +23,14 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable public abstract BaseAddonContainer Addon { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual int OnCraft( diff --git a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs index f84c959cf..decf3ca65 100644 --- a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs @@ -48,17 +48,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Clamp(value, 0, MaxFlour); - UpdateStage(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxFlour); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs index cbd0e2cc3..fa48aa83c 100644 --- a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs @@ -35,16 +35,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Max(0, Math.Min(value, MaxFlour)); - UpdateStage(); - } + value = Math.Max(0, Math.Min(value, MaxFlour)); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/SHTeleporter.cs b/Projects/UOContent/Items/Addons/SHTeleporter.cs index f34f9cb85..e6ce612b4 100644 --- a/Projects/UOContent/Items/Addons/SHTeleporter.cs +++ b/Projects/UOContent/Items/Addons/SHTeleporter.cs @@ -25,35 +25,27 @@ namespace Server.Items _teleOffset = offset; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Active - { - get => _active; - set - { - _active = value; + [SerializableField(0, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _active; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeActive(value); - } + private void OnActiveChanged(bool oldValue, bool newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeActive(newValue); } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public SHTeleComponent TeleDest - { - get => _teleDest; - set - { - _teleDest = value; + [SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private SHTeleComponent _teleDest; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeDest(value); - } + private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeDest(newValue); } } diff --git a/Projects/UOContent/Items/Aquarium/AquariumState.cs b/Projects/UOContent/Items/Aquarium/AquariumState.cs index cdd59d9d9..75fe6fc6f 100644 --- a/Projects/UOContent/Items/Aquarium/AquariumState.cs +++ b/Projects/UOContent/Items/Aquarium/AquariumState.cs @@ -30,19 +30,14 @@ namespace Server.Items public AquariumState(Aquarium parent) => _aquarium = parent; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int State + [SerializableField(0, allowFieldChange: nameof(AllowStateChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _state; + + private bool AllowStateChange(ref int value) { - get => _state; - set - { - if (_state != value) - { - _state = Math.Clamp(value, 0, 4); - MarkDirty(); - } - } + value = Math.Clamp(value, 0, 4); + return true; } [SerializableField(1)] diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 8f87626d7..f4062326d 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -219,25 +219,16 @@ namespace Server.Items private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; - [SerializableProperty(13)] + [SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))] [SaveFlag(nameof(ShouldSerializeProtectionLevel))] - [CommandProperty(AccessLevel.GameMaster)] - public ArmorProtectionLevel ProtectionLevel + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ArmorProtectionLevel _protectionLevel; + + private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue) { - get => _protectionLevel; - set - { - if (_protectionLevel != value) - { - _protectionLevel = value; - - Invalidate(); - InvalidateProperties(); - - (Parent as Mobile)?.UpdateResistances(); - this.MarkDirty(); - } - } + Invalidate(); + (Parent as Mobile)?.UpdateResistances(); } private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; diff --git a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs index f9abd485b..aa023cdc3 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs @@ -33,32 +33,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs index a1ded151b..297bd8ed1 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs @@ -32,32 +32,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 7d106e7c3..983568d2d 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -113,19 +113,15 @@ namespace Server.Items Resistances = new AosElementAttributes(this); } - [SerializableProperty(0)] + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] [SaveFlag(nameof(ShouldSerializeResource))] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - this.MarkDirty(); - } + Hue = CraftResources.GetHue(_resource); } [SerializableProperty(9, useField: nameof(_strReq))] diff --git a/Projects/UOContent/Items/Clothing/Cloaks.cs b/Projects/UOContent/Items/Clothing/Cloaks.cs index 67f572d9d..a92e8fc8c 100644 --- a/Projects/UOContent/Items/Clothing/Cloaks.cs +++ b/Projects/UOContent/Items/Clothing/Cloaks.cs @@ -22,34 +22,26 @@ namespace Server.Items public override double DefaultWeight => 5.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index ea2585ad0..f3b0b077f 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -325,34 +325,26 @@ namespace Server.Items public override double DefaultWeight => 3.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index b82ae2654..f791b3a62 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -70,19 +70,15 @@ namespace Server.Items public override CraftResource DefaultResource => CraftResource.RegularLeather; + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index dc3449c24..ef779760b 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -74,43 +74,31 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable Movable = false; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Open + [SerializableField(1, fieldChanged: nameof(OnOpenChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _open; + + private void OnOpenChanged(bool oldValue, bool newValue) { - get => _open; - set + ItemID = _open ? _openedId : _closedId; + if (_open) { - if (_open != value) - { - _open = value; - - ItemID = _open ? _openedId : _closedId; - - if (_open) - { - Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); - } - else - { - Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); - } - - Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); - - if (_open) - { - _timer ??= new InternalTimer(this); - _timer.Start(); - } - else - { - _timer.Stop(); - _timer = null; - } - - this.MarkDirty(); - } + Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); + } + else + { + Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); + } + Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); + if (_open) + { + _timer ??= new InternalTimer(this); + _timer.Start(); + } + else + { + _timer.Stop(); + _timer = null; } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index 6a7e073f3..04e7c89ea 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -62,23 +62,19 @@ public partial class MarkContainer : LockableContainer } } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool AutoLock - { - get => _autoLock; - set - { - _autoLock = value; + [SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _autoLock; - if (!_autoLock) - { - StopTimer(); - } - else if (!Locked) - { - _relockTimer ??= new InternalTimer(this); - } + private void OnAutoLockChanged(bool oldValue, bool newValue) + { + if (!_autoLock) + { + StopTimer(); + } + else if (!Locked) + { + _relockTimer ??= new InternalTimer(this); } } diff --git a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs index 6599e6c56..5ed370153 100644 --- a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs +++ b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs @@ -28,17 +28,14 @@ public partial class DragonBardingDeed : Item, ICraftable public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(value); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(newValue); } public int OnCraft( diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 3a9f50297..85eecf17f 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -324,27 +324,21 @@ public abstract partial class BaseBeverage : Item, IHasQuantity [CommandProperty(AccessLevel.GameMaster)] public bool IsFull => _quantity >= MaxQuantity; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public BeverageType Content + [SerializableField(2, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private BeverageType _content; + + private void OnContentChanged(BeverageType oldValue, BeverageType newValue) { - get => _content; - set + var itemID = ComputeItemID(); + if (itemID > 0) { - _content = value; - - InvalidateProperties(); - - var itemID = ComputeItemID(); - - if (itemID > 0) - { - ItemID = itemID; - } - else - { - Delete(); - } + ItemID = itemID; + } + else + { + Delete(); } } diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index 6cff16d72..cd010432d 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -76,25 +76,25 @@ public partial class SackFlour : Item, IHasQuantity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Quantity + [SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _quantity; + + private bool AllowQuantityChange(ref int value) { - get => _quantity; - set + value = Math.Min(20, Math.Max(0, value)); + return true; + } + + private void OnQuantityChanged(int oldValue, int newValue) + { + if (_quantity == 0) { - _quantity = Math.Min(20, Math.Max(0, value)); - - if (_quantity == 0) - { - Delete(); - } - else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) - { - ++ItemID; - } - - this.MarkDirty(); + Delete(); + } + else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) + { + ++ItemID; } } diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs index de7e663b7..89e4d6f8f 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs @@ -55,55 +55,33 @@ public partial class MahjongGame : Item, ISecurable public override double DefaultWeight => 5.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(6)] - public bool ShowScores + [SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _showScores; + + private void OnShowScoresChanged(bool oldValue, bool newValue) { - get => _showScores; - set + if (newValue) { - if (_showScores == value) - { - return; - } - - _showScores = value; - - if (value) - { - _players.SendPlayersPacket(true, true); - } - - _players.SendGeneralPacket(true, true); - _players.SendLocalizedMessage(value ? 1062777 : 1062778); // The dealer has enabled/disabled score display. - this.MarkDirty(); + _players.SendPlayersPacket(true, true); } + _players.SendGeneralPacket(true, true); + _players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display. } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public bool SpectatorVision + [SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _spectatorVision; + + private void OnSpectatorVisionChanged(bool oldValue, bool newValue) { - get => _spectatorVision; - set + if (_players.IsInGamePlayer(_players.DealerPosition)) { - if (_spectatorVision == value) - { - return; - } - - _spectatorVision = value; - - if (_players.IsInGamePlayer(_players.DealerPosition)) - { - _players.Dealer.NetState.SendMahjongGeneralInfo(this); - } - - _players.SendTilesPacket(false, true); - _players.SendLocalizedMessage(value ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. - InvalidateProperties(); - this.MarkDirty(); + _players.Dealer.NetState.SendMahjongGeneralInfo(this); } + _players.SendTilesPacket(false, true); + _players.SendLocalizedMessage(newValue ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. } private void BuildHorizontalWall( diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index 4eb01cbe0..e693c0aaa 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -93,16 +93,13 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - } + Hue = CraftResources.GetHue(_resource); } public override int PhysicalResistance => Resistances.Physical; diff --git a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs index a52b1a12d..07df233e9 100644 --- a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs +++ b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs @@ -275,8 +275,16 @@ public partial class BroadcastCrystal : Item [SerializationGenerator(0)] public partial class ReceiverCrystal : Item { + [SerializableField(0, fieldChanged: nameof(OnSenderChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private BroadcastCrystal _sender; + private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue) + { + oldValue?.RemoveReceiver(this); + newValue?.AddReceiver(this); + } + [Constructible] public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150; @@ -297,20 +305,6 @@ public partial class ReceiverCrystal : Item } } - [SerializableProperty(0, useField: nameof(_sender))] - [CommandProperty(AccessLevel.GameMaster)] - public BroadcastCrystal Sender - { - get => _sender; - set - { - _sender?.RemoveReceiver(this); - _sender = value; - value?.AddReceiver(this); - this.MarkDirty(); - } - } - public override void GetProperties(IPropertyList list) { base.GetProperties(list); diff --git a/Projects/UOContent/Items/Misc/MorphItem.cs b/Projects/UOContent/Items/Misc/MorphItem.cs index c5dd9c0de..7e5a74d8b 100644 --- a/Projects/UOContent/Items/Misc/MorphItem.cs +++ b/Projects/UOContent/Items/Misc/MorphItem.cs @@ -30,20 +30,24 @@ public partial class MorphItem : Item _outsideRange = outRange; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int OutsideRange + [SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _outsideRange; + + private bool AllowOutsideRangeChange(ref int value) { - get => _outsideRange; - set => _outsideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int InsideRange + [SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _insideRange; + + private bool AllowInsideRangeChange(ref int value) { - get => _insideRange; - set => _insideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/WarningItem.cs b/Projects/UOContent/Items/Misc/WarningItem.cs index 5d0b491a4..3779e4900 100644 --- a/Projects/UOContent/Items/Misc/WarningItem.cs +++ b/Projects/UOContent/Items/Misc/WarningItem.cs @@ -14,8 +14,16 @@ public partial class WarningItem : Item private TextDefinition _warningMessage; // Field 1 + [SerializableField(1, allowFieldChange: nameof(AllowRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private int _range; + private bool AllowRangeChange(ref int value) + { + value = Math.Min(value, 18); + return true; + } + [SerializableField(2)] private TimeSpan _resetDelay; @@ -39,18 +47,6 @@ public partial class WarningItem : Item _range = Math.Min(range, 18); } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1, useField: nameof(_range))] - public int Range - { - get => _range; - set - { - _range = Math.Min(value, 18); - this.MarkDirty(); - } - } - public virtual bool OnlyToTriggerer => false; public virtual int NeighborRange => 5; diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs index ce772da40..8170b286d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs @@ -16,22 +16,14 @@ public abstract partial class BaseIngot : Item, ICommodity public override double DefaultWeight => 0.1; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs index 3116d7136..6ae0c71c3 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs @@ -17,22 +17,14 @@ public abstract partial class BaseOre : Item _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs index fa63d902c..277ed569d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs @@ -14,22 +14,14 @@ public abstract partial class BaseScales : Item, ICommodity _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1053139; // dragon scales diff --git a/Projects/UOContent/Items/Resources/Masonry/Granite.cs b/Projects/UOContent/Items/Resources/Masonry/Granite.cs index ae4f215d7..089181ebe 100644 --- a/Projects/UOContent/Items/Resources/Masonry/Granite.cs +++ b/Projects/UOContent/Items/Resources/Masonry/Granite.cs @@ -15,22 +15,14 @@ public abstract partial class BaseGranite : Item public override double DefaultWeight => Core.ML ? 1.0 : 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1044607; // high quality granite diff --git a/Projects/UOContent/Items/Resources/Tailor/Hides.cs b/Projects/UOContent/Items/Resources/Tailor/Hides.cs index df6423fb2..d64eb2f0b 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Hides.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Hides.cs @@ -15,22 +15,14 @@ public abstract partial class BaseHides : Item, ICommodity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs index 173e2dae0..afb757e1f 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs @@ -15,22 +15,14 @@ public abstract partial class BaseLeather : Item, ICommodity public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs index 9ff0d8386..53c8077f1 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs @@ -21,22 +21,14 @@ public partial class Board : Item, ICommodity Hue = CraftResources.GetHue(resource); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } int ICommodity.DescriptionNumber diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs index 5d6062099..bf626c2fd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -25,16 +25,14 @@ public partial class MessageInABottle : Item public override int LabelNumber => 1041080; // a message in a bottle - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; } public static int GetRandomLevel() diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index f8450e63f..5374f8716 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -103,18 +103,20 @@ public partial class SOS : Item [CommandProperty(AccessLevel.GameMaster)] public bool IsAncient => _level >= 4; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, fieldChanged: nameof(OnLevelChanged), allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - UpdateHue(); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; + } + + private void OnLevelChanged(int oldValue, int newValue) + { + UpdateHue(); } public void UpdateHue() => Hue = IsAncient ? 0x481 : 0; diff --git a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs index 2873957d5..2a06f535a 100644 --- a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs +++ b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs @@ -28,22 +28,14 @@ public partial class Log : Item, ICommodity, IAxe public override double DefaultWeight => 2.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public virtual bool Axe(Mobile from, BaseAxe axe) => TryCreateBoards(from, 0, new Board()); diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 68b6f324a..14db7ac1e 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -47,36 +47,24 @@ public partial class RecallRune : Item } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public bool Marked + [SerializableField(2, fieldChanged: nameof(OnMarkedChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _marked; + + private void OnMarkedChanged(bool oldValue, bool newValue) { - get => _marked; - set - { - if (_marked != value) - { - _marked = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public Map TargetMap + [SerializableField(4, fieldChanged: nameof(OnTargetMapChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private Map _targetMap; + + private void OnTargetMapChanged(Map oldValue, Map newValue) { - get => _targetMap; - set - { - if (_targetMap != value) - { - _targetMap = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index f1ced7308..9447fddfa 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -133,28 +133,19 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem public virtual int BookOffset => 0; public virtual int BookCount => 64; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public ulong Content + [SerializableField(7, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ulong _content; + + private void OnContentChanged(ulong oldValue, ulong newValue) { - get => _content; - set + // This assignment will mark it as dirty + SpellCount = 0; + while (newValue > 0) { - if (_content != value) - { - _content = value; - - // This assignment will mark it as dirty - SpellCount = 0; - - while (value > 0) - { - _spellCount += (int)(value & 0x1); - value >>= 1; - } - - InvalidateProperties(); - } + _spellCount += (int)(newValue & 0x1); + newValue >>= 1; } } diff --git a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs index dbb8df68c..e4d486d10 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs @@ -62,17 +62,15 @@ public partial class RepairDeed : Item public override bool DisplayLootType => false; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public double SkillLevel + [SerializableField(1, allowFieldChange: nameof(AllowSkillLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillLevel; + + private bool AllowSkillLevelChange(ref double value) { - get => _skillLevel; - set - { - _skillLevel = Math.Clamp(value, 0, 120.0); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, 120.0); + return true; } public override void AddNameProperty(IPropertyList list) diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index b01416211..f7716a4a1 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -74,16 +74,13 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime LastReplenished + [SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _lastReplenished; + + private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue) { - get => _lastReplenished; - set - { - _lastReplenished = value; - CheckReplenishUses(); - } + CheckReplenishUses(); } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs index 6b557b7be..e35cca471 100644 --- a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs +++ b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs @@ -41,20 +41,13 @@ namespace Server.Items public virtual bool AllowDyables => true; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int DyedHue - { - get => _dyedHue; - set - { - if (_redyable) - { - _dyedHue = value; - Hue = value; - } - } - } + [SerializableField(2, allowFieldChange: nameof(AllowDyedHueChange), fieldChanged: nameof(OnDyedHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _dyedHue; + + private bool AllowDyedHueChange(ref int value) => _redyable; + + private void OnDyedHueChanged(int oldValue, int newValue) => Hue = newValue; // Three metallic tubs now. public virtual bool MetallicHues => false; diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs index 6010ec376..595b6b11d 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs @@ -60,17 +60,14 @@ public abstract partial class BaseRunicTool : BaseTool public BaseRunicTool(CraftResource resource, int uses, int itemID) : base(uses, itemID) => _resource = resource; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(_resource); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index b66056831..404fc9012 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -54,17 +54,15 @@ public partial class FountainOfLife : BaseAddonContainer public override int DefaultDropSound => 66; public override int DefaultMaxItems => 125; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, MaxCharges); + return true; } public override bool OnDragLift(Mobile from) => false; @@ -183,16 +181,14 @@ public partial class FountainOfLifeDeed : BaseAddonContainerDeed public override int LabelNumber => 1075197; // Fountain of Life public override BaseAddonContainer Addon => new FountainOfLife(_charges); - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, FountainOfLife.MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, FountainOfLife.MaxCharges); + return true; } } diff --git a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs index 6a7e5af6f..fabb756d8 100644 --- a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs +++ b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs @@ -14,12 +14,14 @@ public abstract partial class BaseFruitTreeAddon : BaseAddon public abstract override BaseAddonDeed Deed { get; } public abstract Item Fruit { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Fruits + [SerializableField(0, allowFieldChange: nameof(AllowFruitsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fruits; + + private bool AllowFruitsChange(ref int value) { - get => _fruits; - set => _fruits = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } public override void OnComponentUsed(AddonComponent c, Mobile from) diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index c24985f9f..59e2b8277 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -146,34 +146,24 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Rectangle2D PlotBounds - { - get => _plotBounds; - set - { - _plotBounds = value; + [SerializableField(3, fieldChanged: nameof(OnPlotBoundsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Rectangle2D _plotBounds; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotBoundsChanged(Rectangle2D oldValue, Rectangle2D newValue) + { + InvalidateRegion(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Map PlotFacet - { - get => _plotFacet; - set - { - _plotFacet = value; + [SerializableField(4, fieldChanged: nameof(OnPlotFacetChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Map _plotFacet; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotFacetChanged(Map oldValue, Map newValue) + { + InvalidateRegion(); } [CommandProperty(AccessLevel.GameMaster)] @@ -190,17 +180,15 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public int TicketPrice + [SerializableField(6, allowFieldChange: nameof(AllowTicketPriceChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private int _ticketPrice; + + private bool AllowTicketPriceChange(ref int value) { - get => _ticketPrice; - set - { - _ticketPrice = Math.Max(0, value); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(0, value); + return true; } public override string DefaultName => "a house raffle stone"; diff --git a/Projects/UOContent/Items/Special/MonsterStatuette.cs b/Projects/UOContent/Items/Special/MonsterStatuette.cs index 8d49dd0e4..fd26a0060 100644 --- a/Projects/UOContent/Items/Special/MonsterStatuette.cs +++ b/Projects/UOContent/Items/Special/MonsterStatuette.cs @@ -162,21 +162,15 @@ public partial class MonsterStatuette : Item, IRewardItem, IGumpToggleItem _ => fallback }; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public MonsterStatuetteType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private MonsterStatuetteType _type; + + private void OnTypeChanged(MonsterStatuetteType oldValue, MonsterStatuetteType newValue) { - get => _type; - set - { - _type = value; - ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; - - Hue = GetStatuetteHue(_type, Hue); - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; + Hue = GetStatuetteHue(_type, Hue); } public override int LabelNumber => MonsterStatuetteInfo.GetInfo(_type).LabelNumber; diff --git a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs index f2772b342..9441fd5c7 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs @@ -36,50 +36,41 @@ public partial class BagOfSending : Item, TranslocationItem public override int LabelNumber => 1054104; // a bag of sending - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue - { - get => _bagOfSendingHue; - set - { - _bagOfSendingHue = value; + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + { + Hue = newValue switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(2, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index a76833e30..070a802b1 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -29,30 +29,26 @@ public partial class BallOfSummoning : Item, TranslocationItem public override double DefaultWeight => 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(2)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 2f82791c2..1d508c4ac 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -27,30 +27,26 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Special/SoulStone.cs b/Projects/UOContent/Items/Special/SoulStone.cs index bcad4a749..d26f6792c 100644 --- a/Projects/UOContent/Items/Special/SoulStone.cs +++ b/Projects/UOContent/Items/Special/SoulStone.cs @@ -83,20 +83,14 @@ public partial class SoulStone : Item, ISecurable } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster)] - public double SkillValue + [SerializableField(6, fieldChanged: nameof(OnSkillValueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillValue; + + private void OnSkillValueChanged(double oldValue, double newValue) { - get => _skillValue; - set - { - _skillValue = value; - - ItemID = IsEmpty ? _inactiveItemID : _activeItemID; - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = IsEmpty ? _inactiveItemID : _activeItemID; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Suits/BaseSuit.cs b/Projects/UOContent/Items/Suits/BaseSuit.cs index 018c010a2..dd1eb00f6 100644 --- a/Projects/UOContent/Items/Suits/BaseSuit.cs +++ b/Projects/UOContent/Items/Suits/BaseSuit.cs @@ -17,20 +17,9 @@ public abstract partial class BaseSuit : Item public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - public AccessLevel AccessLevel - { - get => _accessLevel; - set - { - var oldAccessLevel = _accessLevel; - _accessLevel = value; - InvalidateProperties(); - this.MarkDirty(); - - OnAccessLevelChanged(oldAccessLevel, _accessLevel); - } - } + [SerializableField(0, fieldChanged: nameof(OnAccessLevelChanged))] + [InvalidateProperties] + private AccessLevel _accessLevel; public virtual void OnAccessLevelChanged(AccessLevel oldAccessLevel, AccessLevel accessLevel) { diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index 3853d4594..b3f0c2ed4 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -279,23 +279,17 @@ public partial class BaseTalisman : Item, IAosItem public override int LabelNumber => 1071023; // Talisman public virtual bool ForceShowName => false; // used to override default summoner/removal name - [SerializableProperty(10)] + [SerializableField(10, fieldChanged: nameof(OnChargesChanged))] [SaveFlag(nameof(ShouldSerializeCharges))] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private void OnChargesChanged(int oldValue, int newValue) { - get => _charges; - set + if (_chargeTime > 0) { - _charges = value; - - if (_chargeTime > 0) - { - StartTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + StartTimer(); } } diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 762a0be9c..8516e6678 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -36,16 +36,14 @@ public partial class ShardPoller : Item Movable = false; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public string Title + [SerializableField(0, allowFieldChange: nameof(AllowTitleChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private string _title; + + private bool AllowTitleChange(ref string value) { - get => _title; - set - { - _title = ShardPollPrompt.UrlToHref(value); - this.MarkDirty(); - } + value = ShardPollPrompt.UrlToHref(value); + return true; } [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -54,31 +52,20 @@ public partial class ShardPoller : Item ? TimeSpan.Zero : Utility.Max(StartTime + Duration - Core.Now, TimeSpan.Zero); - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public bool Active + [SerializableField(3, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private bool _active; + + private void OnActiveChanged(bool oldValue, bool newValue) { - get => _active; - set + if (_active) { - if (_active == value) - { - return; - } - - _active = value; - - if (_active) - { - StartTime = Core.Now; - _activePollers.Add(this); - } - else - { - _activePollers.Remove(this); - } - - this.MarkDirty(); + StartTime = Core.Now; + _activePollers.Add(this); + } + else + { + _activePollers.Remove(this); } } @@ -250,15 +237,12 @@ public partial class ShardPollOption } } - [SerializableProperty(0)] - public string Title + [SerializableField(0, fieldChanged: nameof(OnTitleChanged))] + private string _title; + + private void OnTitleChanged(string oldValue, string newValue) { - get => _title; - set - { - _title = value; - _lineBreaks = -1; - } + _lineBreaks = -1; } public int Votes => Voters.Length; diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index b43c34272..32d0883cc 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -43,18 +43,14 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; + [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] [DeltaDateTime] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime NextWoolTime + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _nextWoolTime; + + private void OnNextWoolTimeChanged(DateTime oldValue, DateTime newValue) { - get => _nextWoolTime; - set - { - _nextWoolTime = value; - SheepBody(); - this.MarkDirty(); - } + SheepBody(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index e2018ab14..f6dc13b40 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -40,43 +40,27 @@ namespace Server.Mobiles public override double DefaultWeight => 1.0; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int MountedID - { - get => _mountedID; - set - { - if (_mountedID != value) - { - _mountedID = value; + [SerializableField(2, fieldChanged: nameof(OnMountedIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _mountedID; - if (_rider != null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnMountedIDChanged(int oldValue, int newValue) + { + if (_rider != null) + { + ItemID = newValue; } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int RegularID - { - get => _regularID; - set - { - if (_regularID != value) - { - _regularID = value; + [SerializableField(3, fieldChanged: nameof(OnRegularIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _regularID; - if (_rider == null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnRegularIDChanged(int oldValue, int newValue) + { + if (_rider == null) + { + ItemID = newValue; } } @@ -127,17 +111,15 @@ namespace Server.Mobiles private bool ShouldSerializeRider() => _rider != null; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(5)] + [SerializableField(5, allowFieldChange: nameof(AllowStepsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] [SaveFlag(nameof(ShouldSerializeSteps))] - public int Steps + private int _steps; + + private bool AllowStepsChange(ref int value) { - get => _steps; - set - { - _steps = Math.Clamp(value, 0, StepsMax); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, StepsMax); + return true; } private bool ShouldSerializeSteps() => _steps != StepsMax; diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs index 69bd9ce56..19a061af2 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs @@ -62,48 +62,37 @@ namespace Server.Mobiles [SerializableField(3)] private int _bardingHP; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(2)] - public bool HasBarding - { - get => _hasBarding; - set - { - _hasBarding = value; + [SerializableField(2, fieldChanged: nameof(OnHasBardingChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _hasBarding; - if (_hasBarding) - { - Hue = CraftResources.GetHue(_bardingResource); - Body = 0x31F; - ItemID = 0x3EBE; - } - else - { - Hue = 0x851; - Body = 0x31A; - ItemID = 0x3EBD; - } - InvalidateProperties(); - this.MarkDirty(); + private void OnHasBardingChanged(bool oldValue, bool newValue) + { + if (_hasBarding) + { + Hue = CraftResources.GetHue(_bardingResource); + Body = 0x31F; + ItemID = 0x3EBE; + } + else + { + Hue = 0x851; + Body = 0x31A; + ItemID = 0x3EBD; } } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(4)] - public CraftResource BardingResource + [SerializableField(4, fieldChanged: nameof(OnBardingResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _bardingResource; + + private void OnBardingResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _bardingResource; - set + if (_hasBarding) { - _bardingResource = value; - - if (_hasBarding) - { - Hue = CraftResources.GetHue(value); - } - - InvalidateProperties(); - this.MarkDirty(); + Hue = CraftResources.GetHue(newValue); } } diff --git a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs index e3fbadd97..3f96e5d36 100644 --- a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs +++ b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs @@ -23,19 +23,14 @@ public partial class BaseHire : BaseCreature public int GoldOnDeath { get; set; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool IsHired - { - get => _isHired; - set - { - _isHired = value; + [SerializableField(1, fieldChanged: nameof(OnIsHiredChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _isHired; - Delta(MobileDelta.Noto); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnIsHiredChanged(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); } public BaseHire(AIType AI) : base(AI, FightMode.Aggressor) diff --git a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs index d80c60dcc..bbbc3ff6f 100644 --- a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs +++ b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs @@ -148,18 +148,13 @@ public partial class PlayerBarkeeper : BaseVendor LoadSBInfo(); } - [SerializableProperty(0)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerBarkeepers.Remove(this); - value?.PlayerBarkeepers.Add(this); + [SerializableField(0, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerBarkeepers.Remove(this); + newValue?.PlayerBarkeepers.Add(this); } public override bool IsActiveBuyer => false; diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 2ff6eb812..5b878def2 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -94,18 +94,13 @@ public partial class PlayerVendor : Mobile public PlayerVendorPlaceholder Placeholder { get; set; } - [SerializableProperty(2)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerVendors.Remove(this); - value?.PlayerVendors.Add(this); + [SerializableField(2, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerVendors.Remove(this); + newValue?.PlayerVendors.Add(this); } public int ChargePerDay diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 14af3656a..99d1446e6 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -32,18 +32,20 @@ public partial class VendorItem public string FormattedPrice => Core.ML ? Price.ToString("N0", CultureInfo.GetCultureInfo("en-US")) : Price.ToString(); - [SerializableProperty(2)] - public string Description - { - get => _description; - set - { - _description = value ?? ""; + [SerializableField(2, fieldChanged: nameof(OnDescriptionChanged), allowFieldChange: nameof(AllowDescriptionChange))] + private string _description; - if (Valid) - { - Item.InvalidateProperties(); - } + private bool AllowDescriptionChange(ref string value) + { + value = value ?? ""; + return true; + } + + private void OnDescriptionChanged(string oldValue, string newValue) + { + if (Valid) + { + Item.InvalidateProperties(); } } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 2082056da..9b2d4cc37 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -136,31 +136,23 @@ namespace Server.Multis } } + [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] [DeltaDateTime] - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime TimeOfDecay + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _timeOfDecay; + + private void OnTimeOfDecayChanged(DateTime oldValue, DateTime newValue) { - get => _timeOfDecay; - set - { - _timeOfDecay = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } - [SerializableProperty(10)] - [CommandProperty(AccessLevel.GameMaster)] - public string ShipName + [SerializableField(10, fieldChanged: nameof(OnShipNameChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _shipName; + + private void OnShipNameChanged(string oldValue, string newValue) { - get => _shipName; - set - { - _shipName = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } [CommandProperty(AccessLevel.GameMaster)] From b992c7b955468512b31ab104a8f3d90b662eb11d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:29:27 -0700 Subject: [PATCH 52/64] docs: update serialization docs and skills for generator v4 (#2588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes. **Updated surface, everywhere it was referenced:** - `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional). - `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes. - New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails. - `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express. - `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them). **Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9. **Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern. Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`. --- CLAUDE.md | 2 +- .../claude-skills/modernuo-serialization.md | 96 +++++++----- dev-docs/claude-skills/modernuo-timers.md | 13 +- .../runuo-migration-docs/02-serialization.md | 21 ++- dev-docs/runuo-migration-docs/03-timers.md | 2 +- dev-docs/serialization.md | 140 +++++++++++++++--- dev-docs/timers.md | 8 +- 7 files changed, 219 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 849ff3175..c77d39e19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct -9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` +9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md` 10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code diff --git a/dev-docs/claude-skills/modernuo-serialization.md b/dev-docs/claude-skills/modernuo-serialization.md index c96244656..91e3d5e95 100644 --- a/dev-docs/claude-skills/modernuo-serialization.md +++ b/dev-docs/claude-skills/modernuo-serialization.md @@ -40,21 +40,31 @@ public partial class MyItem : Item { } public partial class MigratedItem : Item { } ``` -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] Applied to `_camelCase` private fields. Generates `PascalCase` property. - `index`: Serialization order (0+) -- `setter`: Access level -- `"private"`, `"internal"`, or omit for public -- `saveIf`: Condition method name for conditional serialization +- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public +- `isVirtual`: Generate a virtual property +- `fieldChanged`: `nameof` of `void Method(T oldValue, T newValue)`, invoked by the generated setter after assignment +- `allowFieldChange`: `nameof` of `bool Method(ref T value)`, invoked before assignment -- coerce through the `ref` parameter or return `false` to reject + +Generated setter pipeline: equality check → `allowFieldChange` → assignment → `MarkDirty` → `InvalidateProperties` (if declared) → `fieldChanged`. The field still holds the old value while the gate runs. Hooks require a generated setter (SG3018 on readonly/setterless fields); a missing or wrong-shaped named method is SG3015. ```csharp -[SerializableField(0)] +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] [SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] private int _charges; -// Generates: public int Charges { get; set; } + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); + return true; +} ``` ### [SerializableProperty(index, useField)] -Applied to properties with custom get/set logic. +Applied to properties with **custom getters** (fallback defaults, lazy/self-healing reads) or setter semantics the field hooks cannot express. For setters that only coerce, veto, or run post-change side effects, use `[SerializableField]` with `allowFieldChange`/`fieldChanged` instead. - `index`: Serialization order - `useField`: Backing field name if auto-detection fails @@ -63,12 +73,12 @@ Applied to properties with custom get/set logic. [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; InvalidateProperties(); - this.MarkDirty(); + this.MarkDirty(); // REQUIRED in custom setters } } ``` @@ -89,8 +99,11 @@ Exposes field to `[Props` gump for in-game editing. ### [EncodedInt] Variable-length int encoding (saves space for small values). +### [AnchoredDateTime] +Stores the absolute UTC instant; shifted by downtime at load so remaining time is preserved. Byte-stable across idle saves. Prefer for deadlines/elapsed-while-running values. + ### [DeltaDateTime] -Stores DateTime as offset from current time (handles server restarts). +Stores DateTime as offset from current time (handles server restarts). Legacy: rewrites bytes every save; prefer `[AnchoredDateTime]` for new fields. Converting between the two changes the wire format (version bump). ### [InternString] Interns strings to reduce memory for repeated values. @@ -128,28 +141,32 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] -Custom timer deserialization. Timer is saved as remaining TimeSpan. +### [DeserializeTimer(nameof(Method), wallClock)] +Required on every serializable `Timer` member (SG3008 otherwise). By default the next tick is stored as **anchored time** (downtime does not consume the remaining delay; idle saves byte-stable); `wallClock: true` stores an absolute deadline instead (delay negative if it passed during downtime). The method -- `void Method(TimeSpan delay)` -- is invoked **only when a timer was running at save**; there is no sentinel to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] -Conditional serialization -- skip fields with default values. +Switching a timer between drifting and `wallClock` changes the wire format: bump the class version and add `MigrateFrom` -- the old content struct exposes `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer ran). + +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +On the serializable field/property itself. Conditional serialization -- skip fields with default values. Second method optional; when omitted, the field keeps its default at load. ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -216,28 +233,35 @@ public partial class ChargedItem : Item } ``` -### Item with Custom Properties +### Item with Setter Hooks (coerce + side effects) ```csharp [SerializationGenerator(2)] public partial class BagOfSending : Item { - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; + + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) { - get => _bagOfSendingHue; - set + Hue = newValue switch { - _bagOfSendingHue = value; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + } + + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) + { + value = Math.Clamp(value, 0, MaxCharges); + return true; } } ``` @@ -328,11 +352,13 @@ public partial class MagicGem ## Real Examples - Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs` - Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs` -- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Setter hooks (allowFieldChange + fieldChanged): `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Custom getters (era fallbacks, the [SerializableProperty] use case): `Projects/UOContent/Items/Weapons/BaseWeapon.cs` - Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs` -- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (wall-clock): `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (drifting/anchored + timer MigrateFrom): `Projects/UOContent/Items/Lights/BaseLight.cs` - Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` -- Conditional serialization: `Projects/Server/Items/Container.cs` +- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs` ## Version Migration Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`: diff --git a/dev-docs/claude-skills/modernuo-timers.md b/dev-docs/claude-skills/modernuo-timers.md index 2c5c8168e..f366b3569 100644 --- a/dev-docs/claude-skills/modernuo-timers.md +++ b/dev-docs/claude-skills/modernuo-timers.md @@ -147,18 +147,27 @@ public partial class DecayingItem : Item } ``` -### [DeserializeTimerField] Pattern (for Timer fields) +### [DeserializeTimer] Pattern (for Timer fields) +Required on every serializable `Timer` member. Drifting by default: the next tick is stored +as anchored time, so server downtime does not consume the remaining delay. Use +`wallClock: true` for absolute deadlines (delay is negative if it passed during downtime). +The method is invoked **only when a timer was running at save** — no sentinel to check. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class's `[SerializationGenerator]` version and add a `MigrateFrom` (the old content +struct exposes `XxxDelay`, `TimeSpan.MinValue` when no timer ran). + ### Custom Timer Class (When You Need Complex Logic) ```csharp private class DecayTimer : Timer diff --git a/dev-docs/runuo-migration-docs/02-serialization.md b/dev-docs/runuo-migration-docs/02-serialization.md index 1470792af..666ebfdb4 100644 --- a/dev-docs/runuo-migration-docs/02-serialization.md +++ b/dev-docs/runuo-migration-docs/02-serialization.md @@ -457,16 +457,24 @@ set ``` Without this, changes won't be saved. +Most RunUO custom setters only clamp the value or run side effects after assignment. Those +convert to a plain `[SerializableField]` with the `allowFieldChange`/`fieldChanged` hooks, +which handle the equality check and `MarkDirty()` for you -- reserve `[SerializableProperty]` +for custom getters (see `dev-docs/serialization.md`). + ### 3. Field Ordering The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version. ### 4. Conditional Serialization -Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values: +Use `[SaveFlag]` on the serializable field to skip default values (the second method is +optional -- omit it and the field keeps its default at load): ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -479,12 +487,15 @@ private List _followers; ``` ### 6. DateTime Fields -Use `[DeltaDateTime]` to survive server restarts: +Use `[AnchoredDateTime]` to survive server restarts -- the value is shifted by downtime at +load, so the remaining time is preserved and idle saves stay byte-stable: ```csharp -[DeltaDateTime] +[AnchoredDateTime] [SerializableField(0)] private DateTime _expireTime; ``` +(`[DeltaDateTime]` is the legacy equivalent; it rewrites bytes on every save. Converting an +existing field between the two changes the wire format and requires a version bump.) ### 7. Keeping Manual Serialization (Rare) Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first. diff --git a/dev-docs/runuo-migration-docs/03-timers.md b/dev-docs/runuo-migration-docs/03-timers.md index 29f3d1282..13596a079 100644 --- a/dev-docs/runuo-migration-docs/03-timers.md +++ b/dev-docs/runuo-migration-docs/03-timers.md @@ -305,7 +305,7 @@ In RunUO, timers are commonly started in `Deserialize()`. In ModernUO, use `[Aft `_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed. ### 4. Timer.DelayCall Still Exists -`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for `[DeserializeTimerField]`) or state-carrying overloads. +`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for a serialized timer field with `[DeserializeTimer]`) or state-carrying overloads. ### 5. Custom Timer Classes Are Still Possible For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases. diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 151d9d717..059fbff69 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -117,7 +117,7 @@ public partial class MyItem : Item { } See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance. -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] **Target**: Private field (`_camelCase`) **Generates**: Public `PascalCase` property with get/set @@ -125,8 +125,11 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g | Parameter | Type | Default | Description | |---|---|---|---| | `index` | `int` | Required | Serialization order (0-based) | -| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter | -| `saveIf` | `string` | `null` | Method name returning bool for conditional save | +| `getter` | `string` | `"public"` | Getter accessibility | +| `setter` | `string` | `"public"` | `"private"` or `"internal"` to restrict setter | +| `isVirtual` | `bool` | `false` | Generate a `virtual` property | +| `fieldChanged` | `string` | `null` | `nameof` of a `void Method(T oldValue, T newValue)` invoked by the generated setter after assignment | +| `allowFieldChange` | `string` | `null` | `nameof` of a `bool Method(ref T value)` invoked before assignment; coerce the value through the `ref` parameter, or return `false` to reject the change | ```csharp [SerializableField(0)] // Public property @@ -144,14 +147,57 @@ The generated property for `_charges` would be: public int Charges { get => _charges; - set { _charges = value; this.MarkDirty(); } + set + { + if (value != _charges) + { + _charges = value; + this.MarkDirty(); + } + } } ``` +**Setter hooks** replace most hand-written `[SerializableProperty]` setters. The generated +pipeline is: equality check → `allowFieldChange` (coerce/veto) → assignment → `MarkDirty` → +`InvalidateProperties` (if declared) → `fieldChanged`. The gate runs before assignment, so +the field itself still holds the old value inside it. + +```csharp +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] +[SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] +private int _charges; + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); // coerce, or return false to veto + return true; +} + +[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))] +private Mobile _owner; + +// oldValue makes unsubscribe/resubscribe patterns trivial +private void OnOwnerChanged(Mobile oldValue, Mobile newValue) +{ + oldValue?.Followers.Remove(this); + newValue?.Followers.Add(this); +} +``` + +Both hooks require a generated setter — declaring one on a `readonly` field or with +`setter: null` is a compile-time error (SG3018), and a named method that is missing or has +the wrong signature is too (SG3015). + ### [SerializableProperty(index, useField)] **Target**: Property with custom get/set logic -**Use when**: You need non-trivial getter/setter logic +**Use when**: You need a **custom getter** (fallback defaults, lazy or self-healing reads) +or setter semantics the field hooks cannot express (work that must run on *equal* +assignment, pre-assignment state capture). For setters that only coerce, veto, or run +post-change side effects, prefer `[SerializableField]` with `allowFieldChange`/`fieldChanged` +instead — the generated setter handles equality, `MarkDirty`, and ordering for you. | Parameter | Type | Default | Description | |---|---|---|---| @@ -163,7 +209,7 @@ public int Charges [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; @@ -173,6 +219,10 @@ public int MaxItems } ``` +Note: the `fieldChanged`/`allowFieldChange` hooks are `[SerializableField]` arguments and +cannot be declared on a `[SerializableProperty]` — its setter is your own code, so call your +methods from the setter directly. + ### [InvalidateProperties] **Target**: `[SerializableField]`-decorated field @@ -208,12 +258,32 @@ Overloads: Best for fields that are usually small values (counts, IDs, indexes). +### [AnchoredDateTime] + +**Target**: `DateTime` field +**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime +between the save and the load (using the save-start anchor in the save's index file), so +server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +sentinels pass through unshifted. + +Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the +stored bytes do not change on every save when the value is unchanged, keeping idle saves +byte-stable. + +```csharp +[AnchoredDateTime] +[SerializableField(0)] +private DateTime _expireTime; +``` + ### [DeltaDateTime] **Target**: `DateTime` field **Effect**: Stores as offset from current time rather than absolute timestamp. -This ensures timers and expiration dates survive server restarts correctly. +Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the +value has not changed. Prefer `[AnchoredDateTime]` for new fields; converting an existing +field between the two changes the wire format and requires a version bump. ```csharp [DeltaDateTime] @@ -289,40 +359,76 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] +### [DeserializeTimer(nameof(Method), wallClock)] -**Target**: Method taking `TimeSpan` parameter -**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay. +**Target**: `Timer`-typed `[SerializableField]` or `[SerializableProperty]` member +**Effect**: Declares how the timer is stored and restored. Required on every serializable +timer (SG3008 otherwise). + +By default the timer's next tick is stored as **anchored time**: server downtime does not +consume the remaining delay, and idle saves are byte-stable. Pass `wallClock: true` to store +an absolute deadline instead (the delay is then negative when the deadline passed during +downtime). + +The named method — `void Method(TimeSpan delay)` — is invoked **only when a timer was +actually running at save**, with the remaining delay. There is no sentinel value to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] -private void DeserializeDecayTimer(TimeSpan delay) +private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete); +``` + +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class version and add a `MigrateFrom`. The old-version content struct exposes the +timer's `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer +was running): + +```csharp +private void MigrateFrom(V3Content content) { - _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +**Target**: the serializable field or property itself **Conditional serialization** -- skip fields that have their default value. +The first method (`bool Method()`) decides whether the value is written. The optional second +method (returning the field's type, no parameters) supplies the value at load when it was +not written; when omitted, the field keeps its default value. + +```csharp +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))] +private int _charges; + +private bool ShouldSerializeCharges() => _charges != -1; + +private int ChargesDefaultValue() => -1; +``` + +Works on `[SerializableProperty]` members the same way: + ```csharp [EncodedInt] [SerializableProperty(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] public int MaxItems { get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; this.MarkDirty(); } } -[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` diff --git a/dev-docs/timers.md b/dev-docs/timers.md index c10f31bd6..2c051cca6 100644 --- a/dev-docs/timers.md +++ b/dev-docs/timers.md @@ -188,15 +188,19 @@ public partial class TimedItem : Item ``` ### Pattern 4: Serializable Timer Field +Every serializable `Timer` member declares `[DeserializeTimer(nameof(Method))]` on the +field. By default the next tick is stored as anchored time (downtime does not consume the +remaining delay); pass `wallClock: true` for absolute deadlines. The method runs **only when +a timer was running at save**, with the remaining delay. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) { _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); } public void BeginDecay(TimeSpan delay) From 2935eafe2476d427865b2e9f0b7eb8be5a9907d5 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:34:43 -0700 Subject: [PATCH 53/64] feat: convert all delta-time serialization to anchored time (#2589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save. The answer to "is it possible everywhere": **yes** — including the one case that looked impossible. ## The GenericPersistence problem, solved `GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected. ## Converted - **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`. - **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`. - **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version. **Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them. ## Verification - Build 0 errors / 0 warnings; **837 + 708 tests green**. - Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched. - **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties). ## Notes for review - `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes. - BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes. ## Enforcement `WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract. --- .../AnchoredItemSerializationTests.cs | 76 +++++++ Projects/Server/Items/Item.cs | 29 ++- Projects/Server/Mobiles/Mobile.cs | 24 ++- Projects/Server/Serialization/BufferWriter.cs | 1 + .../Serialization/GenericEntityPersistence.cs | 4 + .../Serialization/GenericPersistence.cs | 8 +- .../Server/Serialization/IGenericReader.cs | 6 + .../Server/Serialization/IGenericWriter.cs | 3 + Projects/Server/World/World.cs | 9 + .../Engines/CannedEvil/ChampionSpawn.cs | 36 +++- .../Engines/CannedEvil/StarRoomGate.cs | 10 +- .../UOContent/Engines/Ethics/Core/Player.cs | 15 +- .../UOContent/Engines/Khaldun/PuzzleChest.cs | 9 +- .../Engines/ML Quests/MLQuestPersistence.cs | 2 +- .../ML Quests/Objectives/BaseObjective.cs | 4 +- .../Engines/Stealables/StealableArtifacts.cs | 6 +- .../Engines/Virtues/VirtueContext.cs | 35 +++- .../Items/Misc/Corpses/Corpse.Migrations.cs | 179 +++++++++++++++++ .../UOContent/Items/Misc/Corpses/Corpse.cs | 150 +------------- ....Engines.CannedEvil.ChampionSpawn.v11.json | 189 ++++++++++++++++++ ...rver.Engines.Virtues.VirtueContext.v1.json | 119 +++++++++++ .../Migrations/Server.Ethics.Player.v2.json | 50 +++++ .../Migrations/Server.Items.Corpse.v19.json | 137 +++++++++++++ ...r.Items.PuzzleChestSolutionAndTime.v1.json | 14 ++ .../Server.Items.StarRoomGate.v2.json | 22 ++ .../Server.Items.TransientItem.v2.json | 14 ++ .../Server.Mobiles.PlayerVendor.v4.json | 62 ++++++ .../Server.Mobiles.RentedVendor.v1.json | 62 ++++++ .../Migrations/Server.Mobiles.Sheep.v1.json | 14 ++ .../Migrations/Server.Multis.BaseBoat.v5.json | 73 +++++++ .../Migrations/Server.Multis.BaseCamp.v2.json | 34 ++++ .../Server.Spells.Fifth.PoisonField.v1.json | 19 ++ ...Server.Spells.Fourth.FireFieldItem.v1.json | 27 +++ .../Server.Spells.Seventh.EnergyField.v2.json | 19 ++ .../Server.Spells.Sixth.ParalyzeField.v1.json | 19 ++ .../Server.Spells.Third.WallOfStone.v1.json | 19 ++ .../UOContent/Mobiles/Animals/Misc/Sheep.cs | 9 +- Projects/UOContent/Mobiles/BaseCreature.cs | 6 +- .../UOContent/Mobiles/Vendors/PlayerVendor.cs | 15 +- .../UOContent/Mobiles/Vendors/RentedVendor.cs | 15 +- .../Mobiles/Vendors/VendorInventory.cs | 6 +- Projects/UOContent/Multis/Boats/BaseBoat.cs | 19 +- Projects/UOContent/Multis/Camps/BaseCamp.cs | 11 +- .../UOContent/Spells/Fifth/PoisonField.cs | 10 +- Projects/UOContent/Spells/Fourth/FireField.cs | 11 +- .../UOContent/Spells/Seventh/EnergyField.cs | 10 +- .../UOContent/Spells/Sixth/ParalyzeField.cs | 10 +- .../Spellweaving/Items/TransientItem.cs | 9 +- .../UOContent/Spells/Third/WallOfStone.cs | 10 +- 49 files changed, 1417 insertions(+), 223 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs create mode 100644 Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs create mode 100644 Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json create mode 100644 Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Ethics.Player.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Items.Corpse.v19.json create mode 100644 Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json create mode 100644 Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json create mode 100644 Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs new file mode 100644 index 000000000..7889c57c8 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -0,0 +1,76 @@ +using System; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class AnchoredItemSerializationTests +{ + private static byte[] SerializeItem(Item item) + { + var writer = new BufferWriter(new byte[256], true); + item.Serialize(writer); + return writer.Buffer[..(int)writer.Position]; + } + + /// + /// Item v11 stores LastMoved and DecayResetTime as anchored time: the serialized bytes + /// are a function of item state only, not of when the save runs. Pre-v11 stored + /// minutes-since-moved and delta time, which rewrote the bytes on every save. + /// + [Fact] + public void ItemBytes_AreStable_AcrossSavesAtDifferentTimes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(120, 100, 0), Map.Felucca); + item.RestartDecay(); + + var first = SerializeItem(item); + + // A save hours later, with no state change, must produce identical bytes. + Core._now = start + TimeSpan.FromHours(5); + var second = SerializeItem(item); + + Assert.Equal(first, second); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Pre-v11 LastMoved was stored at whole-minute precision relative to the save time and + /// could never round-trip exactly. Anchored storage is absolute and exact. + /// + [Fact] + public void LastMovedAndDecayReset_RoundTripExactly() + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); + + // Sub-minute precision that the old minutes encoding would have destroyed. + var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); + item.LastMoved = moved; + + item.RestartDecay(); + var decayReset = item.DecayResetTime; + Assert.NotEqual(default(DateTime), decayReset); + + var bytes = SerializeItem(item); + + var restored = new Item((Serial)0x7ffff123u); + restored.Deserialize(new BufferReader(bytes)); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(decayReset, restored.DecayResetTime); + + item.Delete(); + } +} diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index a2b63a821..13f249f2f 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -863,7 +863,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(10); // version + writer.Write(11); // version var flags = SaveFlag.None; @@ -1015,19 +1015,13 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - /* begin last moved time optimization */ - var ticks = LastMoved.Ticks; - var now = Core.Now.Ticks; - - var minutes = new TimeSpan(now - ticks).TotalMinutes; - - writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); - /* end */ + // Anchored: shifted by downtime at load, so time-since-moved is preserved and the + // bytes are stable across saves while the item does not move. + writer.WriteAnchoredTime(LastMoved); if (GetSaveFlag(flags, SaveFlag.DecayReset)) { - //TODO Use WriteAnchoredTime once the save-time anchor is ported - writer.WriteDeltaTime(info.m_DecayReset); + writer.WriteAnchoredTime(info.m_DecayReset); } if (GetSaveFlag(flags, SaveFlag.Direction)) @@ -2772,6 +2766,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { + case 11: case 10: case 9: case 8: @@ -2780,7 +2775,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var flags = (SaveFlag)reader.ReadInt(); - if (version < 7) + if (version >= 11) + { + LastMoved = reader.ReadAnchoredTime(); + } + else if (version < 7) { LastMoved = reader.ReadDeltaTime(); } @@ -2800,10 +2799,10 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - var reset = reader.ReadDeltaTime(); + var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); - // LastMoved is stored at whole-minute precision; keep the stamp only - // while it still extends the deadline. + // Pre-v11 LastMoved was stored at whole-minute precision; keep the + // stamp only while it still extends the deadline. if (reset > LastMoved) { DecayResetTime = reset; diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 0e88449e3..fb028b9d7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2324,11 +2324,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual void Serialize(IGenericWriter writer) { - writer.Write(37); // version + writer.Write(38); // version - writer.WriteDeltaTime(LastStrGain); - writer.WriteDeltaTime(LastIntGain); - writer.WriteDeltaTime(LastDexGain); + writer.WriteAnchoredTime(LastStrGain); + writer.WriteAnchoredTime(LastIntGain); + writer.WriteAnchoredTime(LastDexGain); byte hairflag = 0x00; @@ -6150,6 +6150,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro switch (version) { + case 38: // Stat-gain stamps moved from delta time to anchored time case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object) case 36: // Moved virtues to VirtueSystem case 35: // Moved short term murders to PlayerMurderSystem @@ -6158,9 +6159,18 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro case 32: // Removed StuckMenu case 31: { - LastStrGain = reader.ReadDeltaTime(); - LastIntGain = reader.ReadDeltaTime(); - LastDexGain = reader.ReadDeltaTime(); + if (version >= 38) + { + LastStrGain = reader.ReadAnchoredTime(); + LastIntGain = reader.ReadAnchoredTime(); + LastDexGain = reader.ReadAnchoredTime(); + } + else + { + LastStrGain = reader.ReadDeltaTime(); + LastIntGain = reader.ReadDeltaTime(); + LastDexGain = reader.ReadDeltaTime(); + } goto case 30; } diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index 68fd971ba..ab53e2f2f 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter } [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] public void WriteDeltaTime(DateTime value) { if (value == DateTime.MinValue) diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index f27b22740..c7323329f 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -504,6 +504,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); var shift = Core.Now - anchor; _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; + + // The whole save shares one anchor. Publish it so payloads without their own + // (GenericPersistence bins) can shift too; indexes load before any of them. + World.LoadTimeShift = _anchoredTimeShift; } if (version >= 4) diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 5b8e79c29..2268a86c6 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + // These payloads carry no anchor of their own; they inherit the save-wide + // shift stamped while the entity indexes were read (indexes always load + // before persistence payloads — see Persistence.Load). + AnchoredTimeShift = World.LoadTimeShift + }; Deserialize(dataReader); error = dataReader.Position != fileLength diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index 4821a708b..bfa302b39 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -43,6 +43,12 @@ public interface IGenericReader DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc); TimeSpan ReadTimeSpan() => new(ReadLong()); + /// + /// Decodes a legacy delta-time value. Only for reading old-version payloads (version + /// fallbacks and migration replays) — current formats store anchored time and read it + /// with . is + /// obsolete: no current-version format may write delta time. + /// DateTime ReadDeltaTime() { return ReadLong() switch diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 22579ab1a..4162655de 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -40,7 +40,10 @@ public interface IGenericWriter void Write(decimal value); void WriteEncodedInt(int value); void Write(DateTime value); + + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] void WriteDeltaTime(DateTime value); + void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 4111ef0d1..c00fc85f6 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -99,6 +99,15 @@ public static class World /// anchored timestamps can be re-based by the downtime at load. /// public static DateTime SaveStartTime { get; internal set; } + + /// + /// The anchored-time shift for the save currently being loaded: the downtime between the + /// save's start and this load. Stamped while entity indexes are read (they all carry the + /// same anchor, since the whole save shares one ) and applied + /// to every reader of that save's files — including + /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. + /// + public static TimeSpan LoadTimeShift { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index d1e14c834..9daea0ba3 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -27,16 +27,44 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public partial class ChampionSpawn : Item { + private void MigrateFrom(V10Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _activatedByProximity; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextProximityTime; @@ -96,7 +124,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _expireDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(20)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expireTime; @@ -109,7 +137,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _restartDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(23, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; diff --git a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs index a5426e7a5..88acd587e 100644 --- a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs +++ b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs @@ -3,15 +3,21 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class StarRoomGate : Moongate { + private void MigrateFrom(V1Content content) + { + _decays = content.Decays; + _decayTime = content.DecayTime; + } + private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0); [SerializableField(0)] private bool _decays; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _decayTime; diff --git a/Projects/UOContent/Engines/Ethics/Core/Player.cs b/Projects/UOContent/Engines/Ethics/Core/Player.cs index f21fe9eb9..d8aad9eb3 100644 --- a/Projects/UOContent/Engines/Ethics/Core/Player.cs +++ b/Projects/UOContent/Engines/Ethics/Core/Player.cs @@ -5,9 +5,20 @@ using Server.Mobiles; namespace Server.Ethics; [PropertyObject] -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Player : EthicsEntity { + private void MigrateFrom(V1Content content) + { + _mobile = content.Mobile; + _power = content.Power; + _history = content.History; + _steed = content.Steed; + _familiar = content.Familiar; + _shield = content.Shield; + _ethic = content.Ethic; + } + [SerializableField(0, setter: "private")] private Mobile _mobile; @@ -27,7 +38,7 @@ public partial class Player : EthicsEntity [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private Mobile _familiar; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5, setter: "private")] private DateTime _shield; diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index fbb3af7b6..53932c8f0 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -162,10 +162,15 @@ namespace Server.Items } } - [SerializationGenerator(0)] + [SerializationGenerator(1)] public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + _when = content.When; + } + + [AnchoredDateTime] [SerializableField(0)] private DateTime _when; diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs index 5d846d595..d3e39c09f 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs @@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests { base.Serialize(writer); - writer.Write(2); // version + writer.Write(3); // version writer.Write(MLQuestSystem.Contexts.Count); foreach (var context in MLQuestSystem.Contexts.Values) diff --git a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs index 2838c7736..77c3350f5 100644 --- a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs +++ b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs @@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives if (IsTimed) { writer.Write(true); - writer.WriteDeltaTime(EndTime); + writer.WriteAnchoredTime(EndTime); } else { @@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives { if (reader.ReadBool()) { - var endTime = reader.ReadDeltaTime(); + var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (objInstance != null) { diff --git a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs index 396b87dcd..4cd8d8c63 100644 --- a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs +++ b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs @@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence public override void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(1); // version + writer.WriteEncodedInt(2); // version writer.Write(_enabled); @@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence var si = _artifacts[i]; writer.Write(si.Item); - writer.WriteDeltaTime(si.NextRespawn); + writer.WriteAnchoredTime(si.NextRespawn); } } } @@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence for (var i = 0; i < length; i++) { var item = reader.ReadEntity(); - var nextRespawn = reader.ReadDeltaTime(); + var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (i < _artifacts.Length) { diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 490002f43..7524f77ed 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -5,10 +5,29 @@ using Server.Mobiles; namespace Server.Engines.Virtues; [PropertyObject] -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class VirtueContext { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + // Save-flagged values arrive as nullables; unset flags fall back to the same + // defaults the old deserialize left in place. + _lastSacrificeGain = content.LastSacrificeGain ?? default; + _lastSacrificeLoss = content.LastSacrificeLoss ?? default; + _availableResurrects = content.AvailableResurrects ?? 0; + _lastJusticeLoss = content.LastJusticeLoss ?? default; + _lastCompassionLoss = content.LastCompassionLoss ?? default; + _nextCompassionDay = content.NextCompassionDay ?? default; + _compassionGains = content.CompassionGains ?? 0; + _lastValorLoss = content.LastValorLoss ?? default; + _lastHonorUse = content.LastHonorUse ?? default; + _honorActive = content.HonorActive; + _justiceProtection = content.JusticeProtection; + _justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None; + _values = content.Values; + } + + [AnchoredDateTime] [SerializableField(0)] [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -16,7 +35,7 @@ public partial class VirtueContext private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -31,7 +50,7 @@ public partial class VirtueContext private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(3)] [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -39,7 +58,7 @@ public partial class VirtueContext private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(4)] [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -47,7 +66,7 @@ public partial class VirtueContext private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5)] [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] @@ -62,7 +81,7 @@ public partial class VirtueContext private bool ShouldSerializeCompassionGains() => _compassionGains > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(7)] [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -70,7 +89,7 @@ public partial class VirtueContext private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(8)] [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs new file mode 100644 index 000000000..bc9ce047e --- /dev/null +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs @@ -0,0 +1,179 @@ +using System; + +namespace Server.Items; + +public partial class Corpse +{ + // Decay timer and TimeOfDeath moved from delta time to anchored time + private void MigrateFrom(V18Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decay timer moved from [TimerDrift]/[DeserializeTimerField] to [DeserializeTimer] + private void MigrateFrom(V17Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) + private void MigrateFrom(V16Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Folded Murderer bool field into CorpseFlag.Murderer + private void MigrateFrom(V15Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Murderer) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Replaced int Kills snapshot with bool Murderer snapshot + private void MigrateFrom(V14Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Added corpse hair and corpse facial hair + private void MigrateFrom(V13Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + } +} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 59db3dc98..04e9806c9 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(18, false)] +[SerializationGenerator(19, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -106,7 +106,7 @@ public partial class Corpse : Container, ICarvable [SerializableField(1)] private CorpseFlag _flags; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDeath; @@ -120,31 +120,6 @@ public partial class Corpse : Container, ICarvable private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); - private void MigrateFrom(V17Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - _hairItemId = content.HairItemId; - _hairHue = content.HairHue; - _facialHairItemId = content.FacialHairItemId; - _facialHairHue = content.FacialHairHue; - - if (content.DecayTimerDelay != TimeSpan.MinValue) - { - DeserializeDecayTimer(content.DecayTimerDelay); - } - } - [SerializableField(5, setter: "private")] private HashSet _looters; @@ -342,127 +317,6 @@ public partial class Corpse : Container, ICarvable DevourCorpse(); } - // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) - private void MigrateFrom(V16Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Folded Murderer bool field into CorpseFlag.Murderer - private void MigrateFrom(V15Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Murderer) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Replaced int Kills snapshot with bool Murderer snapshot - private void MigrateFrom(V14Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Added corpse hair and corpse facial hair - private void MigrateFrom(V13Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - } - [CommandProperty(AccessLevel.GameMaster)] public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime; diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json new file mode 100644 index 000000000..defcbe100 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json @@ -0,0 +1,189 @@ +{ + "version": 11, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json new file mode 100644 index 000000000..956de91db --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "type": "Server.Engines.Virtues.VirtueContext", + "properties": [ + { + "name": "LastSacrificeGain", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastSacrificeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "AvailableResurrects", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastJusticeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastCompassionLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "NextCompassionDay", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "CompassionGains", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastValorLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastHonorUse", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "HonorActive", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "JusticeProtection", + "type": "Server.Mobiles.PlayerMobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "JusticeStatus", + "type": "Server.Engines.Virtues.JusticeProtectorStatus", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Values", + "type": "int[]", + "usesSaveFlag": true, + "rule": "ArrayMigrationRule", + "ruleArguments": [ + "int", + "PrimitiveTypeMigrationRule", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json new file mode 100644 index 000000000..04f4a0199 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "type": "Server.Ethics.Player", + "properties": [ + { + "name": "Mobile", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Power", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "History", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Steed", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Familiar", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Shield", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Ethic", + "type": "Server.Ethics.Ethic", + "rule": "SerializableInterfaceMigrationRule" + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json new file mode 100644 index 000000000..2cccb267a --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json @@ -0,0 +1,137 @@ +{ + "version": 19, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json new file mode 100644 index 000000000..8424276f6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Items.PuzzleChestSolutionAndTime", + "properties": [ + { + "name": "When", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json new file mode 100644 index 000000000..0a61f7bfe --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "type": "Server.Items.StarRoomGate", + "properties": [ + { + "name": "Decays", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json new file mode 100644 index 000000000..3745c4428 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "type": "Server.Items.TransientItem", + "properties": [ + { + "name": "Expiration", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json new file mode 100644 index 000000000..6b9c9eb1c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json @@ -0,0 +1,62 @@ +{ + "version": 4, + "type": "Server.Mobiles.PlayerVendor", + "properties": [ + { + "name": "ShopName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextPayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "House", + "type": "Server.Multis.BaseHouse", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "BankAccount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HoldGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SellItems", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Mobiles.VendorItem\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Mobiles.VendorItem", + "RawSerializableMigrationRule", + "1", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json new file mode 100644 index 000000000..b42fa33eb --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "type": "Server.Mobiles.RentedVendor", + "properties": [ + { + "name": "RentalDurationId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LandlordRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenterRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenewalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json new file mode 100644 index 000000000..bd224f77f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Mobiles.Sheep", + "properties": [ + { + "name": "NextWoolTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json new file mode 100644 index 000000000..140d3dd64 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json @@ -0,0 +1,73 @@ +{ + "version": 5, + "type": "Server.Multis.BaseBoat", + "properties": [ + { + "name": "MapItem", + "type": "Server.Items.MapItem", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "NextNavPoint", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Facing", + "type": "Server.Direction", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDecay", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "PPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "SPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "TillerMan", + "type": "Server.Items.TillerMan", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Hold", + "type": "Server.Items.Hold", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Anchored", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ShipName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json new file mode 100644 index 000000000..471c645f1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Multis.BaseCamp", + "properties": [ + { + "name": "Items", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Mobiles", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json new file mode 100644 index 000000000..1f16a4d7f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Fifth.PoisonField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json new file mode 100644 index 000000000..3e91c7a4d --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "type": "Server.Spells.Fourth.FireFieldItem", + "properties": [ + { + "name": "Damage", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json new file mode 100644 index 000000000..dd202da03 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "type": "Server.Spells.Seventh.EnergyField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json new file mode 100644 index 000000000..3e834a6b1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Sixth.ParalyzeField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json new file mode 100644 index 000000000..eafcc5945 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Third.WallOfStone", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index 32d0883cc..741093110 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -5,9 +5,14 @@ using System.Runtime.CompilerServices; namespace Server.Mobiles { - [SerializationGenerator(0, false)] + [SerializationGenerator(1, false)] public partial class Sheep : BaseCreature, ICarvable { + private void MigrateFrom(V0Content content) + { + _nextWoolTime = content.NextWoolTime; + } + [Constructible] public Sheep() : base(AIType.AI_Animal, FightMode.Aggressor) { @@ -44,7 +49,7 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] - [DeltaDateTime] + [AnchoredDateTime] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextWoolTime; diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index af85a195a..6c480ce18 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -1843,7 +1843,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(20); // version + writer.Write(21); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1880,7 +1880,7 @@ namespace Server.Mobiles if (_summoned) { - writer.WriteDeltaTime(SummonEnd); + writer.WriteAnchoredTime(SummonEnd); } writer.Write(ControlSlots); @@ -2035,7 +2035,7 @@ namespace Server.Mobiles if (_summoned) { - SummonEnd = reader.ReadDeltaTime(); + SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); new UnsummonTimer(this, SummonEnd - Core.Now).Start(); } diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 5b878def2..9b8f5a2f3 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -22,9 +22,20 @@ public class PlayerVendorTargetAttribute : Attribute; * Next, uncomment the MigrateFrom function and change the `V3Content` type to match the serialization version * before it was bumped. Then run publish.cmd to generate the migration file. */ -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class PlayerVendor : Mobile { + private void MigrateFrom(V3Content content) + { + _shopName = content.ShopName; + _nextPayTime = content.NextPayTime; + _house = content.House; + _owner = content.Owner; + _bankAccount = content.BankAccount; + _holdGold = content.HoldGold; + _sellItems = content.SellItems; + } + private Timer _payTimer; [InvalidateProperties] @@ -32,7 +43,7 @@ public partial class PlayerVendor : Mobile [SerializedCommandProperty(AccessLevel.GameMaster)] private string _shopName; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextPayTime; diff --git a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs index 99d818826..d7d0a3ab6 100644 --- a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs @@ -46,9 +46,20 @@ public class VendorRentalDuration } } -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class RentedVendor : PlayerVendor { + private void MigrateFrom(V0Content content) + { + _rentalDurationId = content.RentalDurationId; + _rentalPrice = content.RentalPrice; + _landlordRenew = content.LandlordRenew; + _renterRenew = content.RenterRenew; + _renewalPrice = content.RenewalPrice; + _rentalGold = content.RentalGold; + _rentalExpireTime = content.RentalExpireTime; + } + private Timer _rentalExpireTimer; public RentedVendor( @@ -93,7 +104,7 @@ public partial class RentedVendor : PlayerVendor [SerializedCommandProperty(AccessLevel.GameMaster)] private int _rentalGold; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(6)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _rentalExpireTime; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs index d4d5ce4bd..8f0a1fba8 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs @@ -37,7 +37,7 @@ namespace Server.Mobiles Items = reader.ReadEntityList(); Gold = reader.ReadInt(); - ExpireTime = reader.ReadDeltaTime(); + ExpireTime = version >= 1 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (Items.Count == 0 && Gold == 0) { @@ -88,7 +88,7 @@ namespace Server.Mobiles public void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(0); // version + writer.WriteEncodedInt(1); // version writer.Write(Owner); writer.Write(VendorName); @@ -98,7 +98,7 @@ namespace Server.Mobiles writer.Write(Items); writer.Write(Gold); - writer.WriteDeltaTime(ExpireTime); + writer.WriteAnchoredTime(ExpireTime); } private class ExpireTimer : Timer diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 9b2d4cc37..ed618b91a 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -19,9 +19,24 @@ namespace Server.Multis Single } - [SerializationGenerator(4, false)] + [SerializationGenerator(5, false)] public abstract partial class BaseBoat : BaseMulti { + private void MigrateFrom(V4Content content) + { + _mapItem = content.MapItem; + _nextNavPoint = content.NextNavPoint; + _facing = content.Facing; + _timeOfDecay = content.TimeOfDecay; + _owner = content.Owner; + _pPlank = content.PPlank; + _sPlank = content.SPlank; + _tillerMan = content.TillerMan; + _hold = content.Hold; + _anchored = content.Anchored; + _shipName = content.ShipName; + } + public enum DryDockResult { Valid, @@ -137,7 +152,7 @@ namespace Server.Multis } [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] - [DeltaDateTime] + [AnchoredDateTime] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDecay; diff --git a/Projects/UOContent/Multis/Camps/BaseCamp.cs b/Projects/UOContent/Multis/Camps/BaseCamp.cs index e3d10dd0b..e60e3e46e 100644 --- a/Projects/UOContent/Multis/Camps/BaseCamp.cs +++ b/Projects/UOContent/Multis/Camps/BaseCamp.cs @@ -6,9 +6,16 @@ using Server.Mobiles; namespace Server.Multis; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseCamp : BaseMulti { + private void MigrateFrom(V1Content content) + { + _items = content.Items; + _mobiles = content.Mobiles; + _decayTime = content.DecayTime; + } + [Tidy] [SerializableField(0, setter: "private")] private List _items; @@ -17,7 +24,7 @@ public abstract partial class BaseCamp : BaseMulti [SerializableField(1, setter: "private")] private List _mobiles; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2, setter: "private")] private DateTime _decayTime; diff --git a/Projects/UOContent/Spells/Fifth/PoisonField.cs b/Projects/UOContent/Spells/Fifth/PoisonField.cs index 3c5d2f609..92a797f19 100644 --- a/Projects/UOContent/Spells/Fifth/PoisonField.cs +++ b/Projects/UOContent/Spells/Fifth/PoisonField.cs @@ -64,13 +64,19 @@ public class PoisonFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class PoisonField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Fourth/FireField.cs b/Projects/UOContent/Spells/Fourth/FireField.cs index 0b0a22129..8f20f995b 100644 --- a/Projects/UOContent/Spells/Fourth/FireField.cs +++ b/Projects/UOContent/Spells/Fourth/FireField.cs @@ -67,16 +67,23 @@ public class FireFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class FireFieldItem : Item { + private void MigrateFrom(V0Content content) + { + _damage = content.Damage; + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private int _damage; [SerializableField(1)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] private DateTime _end; private Timer _timer; diff --git a/Projects/UOContent/Spells/Seventh/EnergyField.cs b/Projects/UOContent/Spells/Seventh/EnergyField.cs index 5e3ea1149..dbfece36d 100644 --- a/Projects/UOContent/Spells/Seventh/EnergyField.cs +++ b/Projects/UOContent/Spells/Seventh/EnergyField.cs @@ -77,13 +77,19 @@ public class EnergyFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class EnergyField : Item { + private void MigrateFrom(V1Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs index 830f16dfe..301cee507 100644 --- a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs +++ b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs @@ -77,13 +77,19 @@ public class ParalyzeFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class ParalyzeField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs index 54ac52030..49894cc95 100644 --- a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs +++ b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs @@ -3,12 +3,17 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class TransientItem : Item { + private void MigrateFrom(V1Content content) + { + _expiration = content.Expiration; + } + private TimerExecutionToken _timerToken; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expiration; diff --git a/Projects/UOContent/Spells/Third/WallOfStone.cs b/Projects/UOContent/Spells/Third/WallOfStone.cs index 7cae3b848..1b1dd04d3 100644 --- a/Projects/UOContent/Spells/Third/WallOfStone.cs +++ b/Projects/UOContent/Spells/Third/WallOfStone.cs @@ -63,13 +63,19 @@ public class WallOfStoneSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class WallOfStone : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; From 8e39da2810e2d518ea005fc7f8e37f15860c8c52 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:13:00 -0700 Subject: [PATCH 54/64] fix: creatures track and chase targets reliably around corners (#2590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Fixes the long-standing reports of monsters losing track of players who run around a corner ("Is monster AI not using pathfinding? It seems to be LOS blocked by statics"). Root-cause investigation compared current behavior against RunUO line-by-line and traced the regressions through the AI overhaul era (#2232, #2246, #2379, #2401, #2461). ### Root causes and fixes 1. **Movement contract** — `MoveTo`/`ApproachTarget` returned false on every healthy mid-chase tick (true only on arrival), so MeleeAI's RunUO-inherited *"move failed and beyond RangePerception+1 → Guard"* clause — which RunUO only evaluated on genuine blockage — fired **every tick of every chase**. A mounted player trivially opens 17 tiles at a corner, the monster guards, Guard nulls the combatant, and re-acquisition is LOS-gated — unrecoverable through a wall. Movement now reports failure only on genuine failure (no step taken with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash. 2. **Last-known-position pursuit** — while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall), the creature walks to the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace, which had decayed to a single tick since #2246), and **re-engages instantly** if the same target re-enters view — bypassing the 10s reacquire throttle. 3. **`ChaseLeashRange`** — new virtual on BaseCreature (default `RangePerception * 2` = 32 tiles) replaces the inline `RangePerception * 3` (48) in Melee/Mage/Archer AI. Per-creature tunable via `[props`. 4. **Group movement demoted to a crowding refinement** — previously any uncontrolled creature with one ally within 8 tiles on the same target used greedy ring-stepping for the *entire* chase, with wall-slides counted as success, never invoking the pathfinder — the "aggroed but won't come around the corner" symptom for spawn groups. It now engages only near the target when allies actually contest the ring, and blocked/wall-slid steps escalate to the pathfinding approach primitive. 5. **Mages close distance on broken LOS** — a mage within casting range but LOS-blocked by geometry stood at the wall holding a spell target until the 60s combatant expiry (ProcessTarget short-circuits Think and its RunTo stands off at RangeFight). Geometry-blocked mages now close in until LOS returns, both pre-cast and while holding a target. Hidden targets (CanSee) and poison-cure priority unchanged. The new movement contract also stops the constant spurious `OnFailedMove` teleport rolls mid-chase. 6. **Move budget: one actual step per AI tick** — nothing advanced `NextMove` on a normal step (RunUO's `m_NextMove` budget was lost), so code paths attempting several moves in one think tick could cross multiple tiles at once — visible as "warping" when crowded creatures jockey for position. A successful step now consumes a half-step budget (floor 50ms): blocks intra-tick double moves, stays safely below the timer interval so legitimate next-tick moves are never jitter-throttled, and does not reintroduce `TransformMoveDelay` inflation. Blocked attempts consume nothing, so retry ladders (repath-and-step, the collision fan) are unaffected. `CanMoveNow` is also wraparound-safe now. ### Reference behavior RunUO requires LOS to *acquire* a target and to *land* a hit or spell — never to *continue* a chase (its MeleeAI LOS bail-out is literally commented out in stock code). Chases drop only on: target hidden, target dead/off-map, beyond `RangePerception * 3`, 60s without combat interaction, or blocked movement while far away. This PR restores those semantics while adding the last-known-position investigation on top. NPC run flags are untouched — pace is AI-timer-driven and most NPC art has no run animation. --- Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 52 ++++--- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 82 ++++++++-- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 140 +++++++++++++++++- Projects/UOContent/Mobiles/AI/MageAI.cs | 30 +++- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 7 + 7 files changed, 277 insertions(+), 38 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dd461a03f..dedd914c9 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -47,7 +47,7 @@ public class ArcherAI : BaseAI { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { this.DebugSayFormatted($"I have lost {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 08a745296..548fe0d54 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -36,10 +36,32 @@ public abstract partial class BaseAI } } - private bool UseGroupMovement(Mobile target) => + /// + /// Crowding refinement for the final approach: engages only near the target when allies + /// contest the ring, so creatures spread instead of stacking. Chasing any real distance + /// always uses the pathfinding approach primitive. + /// + private bool UseGroupMovement(Mobile target, int range) => Mobile.Combatant == target && !Mobile.Controlled - && CountNearbyAllies(target) > 0; + && Mobile.InRange(target, range + 2) + && CountCrowdingAllies(target, range) > 0; + + private int CountCrowdingAllies(Mobile target, int range) + { + var crowding = 0; + + foreach (var m in target.GetMobilesInRange(range + 1)) + { + if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc + && bc.Team == Mobile.Team) + { + crowding++; + } + } + + return crowding; + } public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) { @@ -56,6 +78,7 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { + return ai.MoveToWithCollisionAvoidance(target, run, range); } @@ -68,7 +91,15 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - return ai.DoMove(direction, true); + var res = ai.DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) + { + return true; + } + + // A blocked or wall-slid step is not progress — route around the obstacle. + return ai.ApproachTarget(target, run, range); } finally { @@ -76,21 +107,6 @@ public abstract partial class BaseAI } } - private int CountNearbyAllies(Mobile target) - { - var allies = 0; - foreach (var m in Mobile.GetMobilesInRange(8)) - { - if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc - && bc.Team == Mobile.Team) - { - allies++; - } - } - - return allies; - } - private PooledRefList GetNearbyAllies(Mobile target) { var allies = PooledRefList.Create(); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1063dd70a..06282a7cb 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * ************************************************************************/ +using System; using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; @@ -58,7 +59,15 @@ public abstract partial class BaseAI public bool CanMoveNow(out double delay) { delay = 0.0; - return Core.TickCount >= NextMove; + return Core.TickCount - NextMove >= 0; + } + + // Caps movement at one actual step per AI think tick; pacing itself is the timer's + // cadence. Half a step keeps the budget below the timer interval so a legitimate + // next-tick move is never jitter-throttled. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (int)(Mobile.CurrentSpeed * 500)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -103,6 +112,8 @@ public abstract partial class BaseAI Mobile.CurrentSpeed = Mobile.PassiveSpeed; } + ConsumeMoveBudget(); + return MoveResult.Success; } @@ -151,6 +162,7 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { + ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -341,31 +353,72 @@ public abstract partial class BaseAI if (res == MoveResult.BadState) { - return false; // not allowed to move this tick; not a stall + return true; // not allowed to move this tick (frozen/casting/throttled); not a failure } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { + ResetApproach(); - return Mobile.InRange(target, range); + return true; // healthy en-route progress } + // else: fall through; let the PathFollower route around the obstacle. } // PLANNING PATH: a persistent PathFollower, never discarded by a greedy step. if (Path == null || Path.Goal != target) { + Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl }; } + // Sample move-eligibility BEFORE the attempt: a successful step consumes the move + // budget, which would mask stall accounting and the progress signal. + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + if (Path.Follow(run, range)) { ResetApproach(); return true; } - TrackApproachProgress(target); - return false; + TrackApproachProgress(target, couldMove); + + // En-route progress is success; failure only when a move-eligible tick took no step + // (no working path), or the approach has given up. + var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove); + + return progressed; + } + + /// + /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around + /// obstacles. Returns false on arrival or when genuinely unable to make progress. + /// + public bool MoveToPoint(IPoint3D goal, bool run) + { + if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) + { + return false; + } + + if (Path?.Goal != goal) + { + Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; + } + + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + + if (Path.Follow(run, 1)) + { + Path = null; + return false; // arrived + } + + return Mobile.Location != locBefore || !couldMove; } /// @@ -376,11 +429,11 @@ public abstract partial class BaseAI /// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick, /// so chases never give up even when the gap holds constant. /// - private void TrackApproachProgress(Mobile target) + private void TrackApproachProgress(Mobile target, bool couldMove) { - if (!CanMoveNow(out _)) + if (!couldMove) { - return; // a not-yet-due move (stun) is not a stall + return; // a tick that was never allowed to move (stun, stall) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -408,6 +461,7 @@ public abstract partial class BaseAI if (++_approachStallTicks >= ApproachGiveUpTicks) { + _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; @@ -444,7 +498,7 @@ public abstract partial class BaseAI return true; } - if (UseGroupMovement(m)) + if (UseGroupMovement(m, range)) { return MoveToWithGroup(this, m, shouldRun, range); } @@ -467,7 +521,11 @@ public abstract partial class BaseAI var direction = Mobile.GetDirectionTo(target); - if (DoMove(direction, true)) + // Wall-slide auto-turns must not count as progress, or a creature pinned on + // geometry reports success forever. + var res = DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) { return true; } @@ -476,14 +534,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMove(clockwise, true)) + if (DoMoveImpl(clockwise, true) == MoveResult.Success) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMove(counterclockwise, true)) + if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 57b6f6a25..4b872ee79 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,11 +26,25 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // Last-known-position tracking: recorded while the combatant is in LOS; drives the + // guard-time investigation and the instant re-engage. + private const int GuardGraceDuration = 10_000; + private const int LkpFreshDuration = 30_000; + private const int InvestigateDuration = 15_000; + private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; + private Mobile _lkpTarget; + private Point3D _lkpLocation; + private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private long _lkpExpireTick; + private long _guardStopTick; + private long _investigateStopTick; + private bool _investigating; + public PathFollower Path { get; protected set; } public AITimer AITimer { get; } public long NextMove { get; set; } @@ -233,6 +247,15 @@ public abstract partial class BaseAI return true; } + if (_action == ActionType.Combat) + { + UpdateLastKnownLocation(); + } + else if (_action is ActionType.Wander or ActionType.Guard) + { + TryReengageLastKnown(); + } + switch (Action) { case ActionType.Wander: @@ -323,6 +346,15 @@ public abstract partial class BaseAI { Mobile.Warmode = true; Mobile.Combatant = null; + + // Investigate a fresh last-seen position that is not already in view; the guard + // grace period begins once the investigation ends. + _investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 && + !(Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)); + _investigateStopTick = Core.TickCount + InvestigateDuration; + _guardStopTick = Core.TickCount + GuardGraceDuration; + _lkpGoal = null; } private void HandleFleeAction() @@ -453,18 +485,118 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (Mobile.Combatant == null) + if (_investigating) { - DebugSay("No threats found. Going home..."); - Action = ActionType.Wander; + if (InvestigateLastKnown()) + { + return true; + } + + _investigating = false; + _guardStopTick = Core.TickCount + GuardGraceDuration; } - DebugSay("I stopped being on guard."); + if (Core.TickCount - _guardStopTick < 0) + { + DebugSay("I am on guard."); + + if (Utility.Random(8) == 0) + { + Mobile.Direction = (Direction)Utility.Random(8); + } + + return true; + } + + DebugSay("I stopped being on guard. Going home..."); Action = ActionType.Wander; return true; } + /// + /// Records the combatant's position while it is visible and in line of sight. + /// + private void UpdateLastKnownLocation() + { + var combatant = Mobile.Combatant; + + if (combatant?.Deleted == false && combatant.Map == Mobile.Map && + Mobile.CanSee(combatant) && Mobile.InLOS(combatant)) + { + _lkpTarget = combatant; + _lkpLocation = combatant.Location; + _lkpExpireTick = Core.TickCount + LkpFreshDuration; + } + } + + /// + /// Re-engages the last-seen target when it returns to view within perception range, + /// bypassing the reacquire throttle. + /// + private bool TryReengageLastKnown() + { + var target = _lkpTarget; + + if (target == null) + { + return false; + } + + if (target.Deleted || !target.Alive || target.Map != Mobile.Map || + target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0) + { + ClearLastKnown(); + return false; + } + + if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None) + { + return false; + } + + if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) || + !Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false)) + { + return false; + } + + DebugSay("There you are!"); + Mobile.Combatant = target; + Mobile.FocusMob = null; + Action = ActionType.Combat; + return true; + } + + /// + /// Walks toward the last-seen position until it is in view, reached, timed out, or + /// unreachable. Returns false when the investigation is finished. + /// + private bool InvestigateLastKnown() + { + if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0) + { + return false; + } + + if (Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)) + { + DebugSay("They truly disappeared..."); + return false; + } + + _lkpGoal ??= _lkpLocation; + return MoveToPoint(_lkpGoal, false); + } + + private void ClearLastKnown() + { + _lkpTarget = null; + _lkpGoal = null; + _investigating = false; + } + public virtual bool DoActionFlee() { var from = Mobile.Combatant; diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index e9450db98..61be59a1f 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) + else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } @@ -695,6 +695,23 @@ public class MageAI : BaseAI } } + // Geometry (not hiding — CanSee passed above) is blocking the shot: close in until + // line of sight returns. Poisoned mages still fall through to cure. + if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c)) + { + DebugSay("I cannot see my target, moving to regain line of sight"); + + if (!MoveTo(c, false, 1)) + { + OnFailedMove(); + } + + _lastTarget = c; + _lastTargetLoc = c.Location; + + return true; + } + if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c)) { DebugSay("I used my abilities!"); @@ -1018,7 +1035,16 @@ public class MageAI : BaseAI if (toTarget != null) { - RunTo(toTarget); + // Without line of sight the stand-off is pointless — close in so the held + // target can be invoked. + if (!Mobile.InLOS(toTarget)) + { + MoveTo(toTarget, true, 1); + } + else + { + RunTo(toTarget); + } } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index aa262caee..544770069 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -82,7 +82,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 6c480ce18..d1a0c7faf 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -660,6 +660,13 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int RangePerception { get; set; } + /// + /// How far a chase may stretch before the creature gives up its combatant. Between + /// RangePerception and this leash it keeps chasing but may switch to closer targets. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual int ChaseLeashRange => RangePerception * 2; + [CommandProperty(AccessLevel.GameMaster)] public int RangeFight { get; set; } From e7f85d404d52e0def1fb342b3dc185894a57017d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:19:59 -0700 Subject: [PATCH 55/64] feat: Adds independent think/move clocks for creature AI to fix speed (#2591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits creature speed into two clocks so movement pace can be tuned without touching reaction time: - **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence. - **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value. ### How - Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22). - `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs. - `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes. - `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes. - The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly. ### Behavior changes - **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted. - **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock. - **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep. - **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles. - **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too. - Removes the vestigial `MoveSpeedMod` (never read, written, or serialized). - With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before. ### Testing - Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding). - In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes. --- Distribution/Data/npc-speeds.json | 10 + .../Tests/Mobiles/AI/MoveSpeedTests.cs | 219 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 122 ++++++++-- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 70 +++++- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 19 +- Projects/UOContent/Mobiles/BaseCreature.cs | 159 ++++++++++++- Projects/UOContent/Mobiles/NPCSpeeds.cs | 23 ++ Projects/UOContent/Mobiles/Special/Paragon.cs | 3 + .../modernuo-content-patterns.md | 6 + dev-docs/content-patterns.md | 29 +++ 10 files changed, 620 insertions(+), 40 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs diff --git a/Distribution/Data/npc-speeds.json b/Distribution/Data/npc-speeds.json index 6e07859d5..707f61e4a 100644 --- a/Distribution/Data/npc-speeds.json +++ b/Distribution/Data/npc-speeds.json @@ -3,12 +3,16 @@ "level": "VerySlow", "active": 0.4, "passive": 0.8, + "activeMove": 0.9, + "passiveMove": 1.5, "types": [] }, { "level": "Slow", "active": 0.3, "passive": 0.6, + "activeMove": 0.6, + "passiveMove": 1.2, "types": [ "AntLion", "ArcticOgreLord", "BogThing", "Bogle", "BoneKnight", "EarthElemental", @@ -28,6 +32,8 @@ "level": "Medium", "active": 0.25, "passive": 0.5, + "activeMove": 0.45, + "passiveMove": 1.05, "types": [ "AcidElemental", "AgapiteElemental", "Alligator", "AncientLich", "Betrayer", "Bird", @@ -108,6 +114,8 @@ "level": "Fast", "active": 0.2, "passive": 0.4, + "activeMove": 0.3, + "passiveMove": 0.9, "types": [ "LordOaks", "Silvani", "AirElemental", "AncientWyrm", "Balron", "BladeSpirits", @@ -139,6 +147,8 @@ "level": "VeryFast", "active": 0.125, "passive": 0.30, + "activeMove": 0.125, + "passiveMove": 0.6, "types": [ "Barracoon", "Mephitis", "Neira", "Rikktor", "Semidar", "EnergyVortex", diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs new file mode 100644 index 000000000..7d9243a29 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching +// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail. +[Collection("Sequential UOContent Tests")] +public class MoveSpeedTests : IDisposable +{ + // Delete spawned stubs so they don't linger in the shared static World. + private readonly List _created = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class SpeedStub : BaseCreature + { + // Stands in for the npc-speeds table (unconfigured in the test fixture). + public double TableActiveMove; + public double TablePassiveMove; + + public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9; + + public SpeedStub(Serial serial) : base(serial) => Body = 0xC9; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + + public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + activeMoveSpeed = TableActiveMove; + passiveMoveSpeed = TablePassiveMove; + } + } + + private SpeedStub NewCreature() + { + var bc = new SpeedStub(); + _created.Add(bc); + return bc; + } + + [Fact] + public void MoveSpeeds_InheritThinkValues_ByDefault() + { + var bc = NewCreature(); + + Assert.Equal(0.3, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // SetSpeed left the creature passive; the think clock is untouched. + Assert.Equal(0.6, bc.CurrentSpeed); + Assert.Equal(0.9, bc.CurrentMoveSpeed); + + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentSpeed); + Assert.Equal(0.45, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_BespokePace_StaysFused() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // Neither think value verbatim, so both clocks run it. + bc.CurrentSpeed = 0.11; + Assert.Equal(0.11, bc.CurrentMoveSpeed); + } + + [Fact] + public void SetSpeed_ClearsMoveOverrides() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.SetSpeed(0.2, 0.4); + + Assert.Equal(0.2, bc.ActiveMoveSpeed); + Assert.Equal(0.4, bc.PassiveMoveSpeed); + } + + [Fact] + public void NonPositiveMoveSpeed_ClearsThatOverride() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.ActiveMoveSpeed = 0; + + Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again + Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched + } + + [Fact] + public void ScaleMoveSpeed_ScalesOverrides_LeavesInheritAlone() + { + var bc = NewCreature(); + bc.ActiveMoveSpeed = 0.6; // passive left inheriting + + bc.ScaleMoveSpeed(1.0 / 1.2); + + Assert.Equal(0.5, bc.ActiveMoveSpeed); + Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar + } + + [Fact] + public void Herding_DrivesMoveClock_ThinkUntouched() + { + var bc = NewCreature(); // think 0.3/0.6, passive + bc.SetMoveSpeed(0.45, 1.05); + + bc.TargetLocation = new Point2D(10, 10); + + Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding + Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05 + + bc.TargetLocation = null; + Assert.Equal(1.05, bc.CurrentMoveSpeed); + } + + [Fact] + public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues() + { + var bc = NewCreature(); + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + bc.SetMoveSpeed(0.45, 0.9); + + // 0.45 and 0.9 do not survive /1.2 then *1.2 bit-exactly. + bc.ScaleMoveSpeed(1.0 / 1.2); + bc.ScaleMoveSpeed(1.2); + Assert.NotEqual(0.45, bc.ActiveMoveSpeed); + + bc.SnapSpeedsToTable(); + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + + // A hand-tuned value is nowhere near the epsilon and must keep. + bc.SetMoveSpeed(0.7, 0.9); + bc.SnapSpeedsToTable(); + Assert.Equal(0.7, bc.ActiveMoveSpeed); + } + + [Fact] + public void Migration_MatchingThinkSpeeds_AdoptTableMoveValues() + { + var bc = NewCreature(); // think 0.3/0.6, matching its table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + } + + [Fact] + public void Migration_TunedThinkSpeeds_KeepInheriting() + { + var bc = NewCreature(); + bc.SetSpeed(0.35, 0.6); // hand-tuned: no longer matches the table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.35, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MoveSpeedOverrides_SurviveSerialization(bool overridden) + { + var bc = NewCreature(); + if (overridden) + { + bc.SetMoveSpeed(0.45, 0.9); + } + + var writer = new BufferWriter(true); + bc.Serialize(writer); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new SpeedStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + // The v22 tail is the last block; exact consumption catches any offset mistake. + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); + Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 06282a7cb..ccd1c72b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -38,7 +38,18 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - public static double BadlyHurtMoveDelay(BaseCreature bc) + // --- Move intent (see ContinueMove) ------------------------------------------------ + // Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while + // live, the AITimer wakes at NextMove between think ticks to advance the step. + private Mobile _moveIntentTarget; + private IPoint3D _moveIntentPoint; + private bool _moveIntentRun; + private int _moveIntentRange; + private long _moveIntentExpire; + + // Inflates a step delay while badly hurt; computed from the passed base so it cannot + // compound across steps. Damage slows steps, never decisions. + public static double BadlyHurtMoveDelay(BaseCreature bc, double delay) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -46,14 +57,15 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var hits = (double)statMin / statMax; + var stat = (double)statMin / statMax; - if (hits < 0.1) { return bc.CurrentSpeed + 0.15; } - if (hits < 0.2) { return bc.CurrentSpeed + 0.1; } - if (hits < 0.3) { return bc.CurrentSpeed + 0.05; } + if (stat < 0.1) { return delay + 0.15; } + if (stat < 0.2) { return delay + 0.1; } + + return delay + 0.05; } - return bc.CurrentSpeed; + return delay; } public bool CanMoveNow(out double delay) @@ -62,12 +74,23 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Caps movement at one actual step per AI think tick; pacing itself is the timer's - // cadence. Half a step keeps the budget below the timer interval so a legitimate - // next-tick move is never jitter-throttled. + // Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly + // regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step. private void ConsumeMoveBudget() { - NextMove = Core.TickCount + Math.Max(50, (int)(Mobile.CurrentSpeed * 500)); + var stepDelay = Mobile.CurrentMoveSpeed; + + if (!(Core.AOS && IsFollowingMaster())) + { + stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + } + + NextMove += Math.Max(50, (long)(stepDelay * 1000)); + + if (Core.TickCount - NextMove > 0) + { + NextMove = Core.TickCount; + } } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -95,21 +118,18 @@ public abstract partial class BaseAI if (TryMove(d)) { + // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. if (Core.AOS && IsFollowingMaster()) { Mobile.CurrentSpeed = 0.1; } - else if (Mobile.Hits < Mobile.HitsMax * 0.3) - { - Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); - } else if (Mobile.Warmode || Mobile.Combatant != null) { - Mobile.CurrentSpeed = Mobile.ActiveSpeed; + Mobile.SetCurrentSpeedToActive(); } else { - Mobile.CurrentSpeed = Mobile.PassiveSpeed; + Mobile.SetCurrentSpeedToPassive(); } ConsumeMoveBudget(); @@ -319,12 +339,14 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { + ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); + ClearMoveIntent(); return true; } @@ -333,12 +355,15 @@ public abstract partial class BaseAI { if (target.Location == _approachGaveUpGoalLoc) { + ClearMoveIntent(); return false; } ResetApproach(); // target moved — try again fresh } + RenewMoveIntent(target, null, run, range); + // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An // auto-turn sidestep can reduce Euclidean distance while moving in the wrong @@ -401,6 +426,7 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { + ClearMoveIntent(); return false; } @@ -409,16 +435,26 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } + RenewMoveIntent(null, goal, run, 1); + var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; if (Path.Follow(run, 1)) { Path = null; + ClearMoveIntent(); return false; // arrived } - return Mobile.Location != locBefore || !couldMove; + var progressed = Mobile.Location != locBefore || !couldMove; + + if (!progressed) + { + ClearMoveIntent(); + } + + return progressed; } /// @@ -461,10 +497,10 @@ public abstract partial class BaseAI if (++_approachStallTicks >= ApproachGiveUpTicks) { - _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; + ClearMoveIntent(); } } @@ -480,6 +516,56 @@ public abstract partial class BaseAI _approachGaveUp = false; } + private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) + { + _moveIntentTarget = target; + _moveIntentPoint = point; + _moveIntentRun = run; + _moveIntentRange = range; + + // A live pursuit renews every think tick; unrenewed intent dies on its own. + _moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250; + } + + public void ClearMoveIntent() + { + _moveIntentTarget = null; + _moveIntentPoint = null; + } + + /// + /// True while a durable movement goal is live; is the tick + /// the movement budget elapses. + /// + public bool TryGetMoveWake(out long nextMove) + { + nextMove = NextMove; + + return (_moveIntentTarget != null || _moveIntentPoint != null) && + Core.TickCount - _moveIntentExpire < 0; + } + + /// + /// Advances the current pursuit/investigation by one step on a movement-clock wake; + /// no decisions run. + /// + public void ContinueMove() + { + if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0) + { + return; + } + + if (_moveIntentTarget != null) + { + ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + } + else + { + MoveToPoint(_moveIntentPoint, _moveIntentRun); + } + } + public virtual bool MoveTo(Mobile m, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 3c08d944b..fe24e5f6d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,9 +17,15 @@ using System; namespace Server.Mobiles; +/// +/// Drives an AI on two clocks: decisions at , plus +/// move-only wakes at while a pursuit is live. Each tick +/// schedules the earlier of the two deadlines. +/// public sealed class AITimer : Timer { private readonly BaseAI _owner; + private long _nextThink; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -28,14 +34,29 @@ public sealed class AITimer : Timer { _owner = owner; _owner._nextDetectHidden = Core.TickCount; + _nextThink = Core.TickCount; } public void Activate() { + _nextThink = Core.TickCount; Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); Start(); } + // A speed-up must not wait out a stale, longer think deadline. + public void OnSpeedChanged() + { + var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + + if (candidate - _nextThink < 0) + { + _nextThink = candidate; + } + + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + } + protected override void OnTick() { if (ShouldStop()) @@ -44,23 +65,52 @@ public sealed class AITimer : Timer return; } - _owner.Mobile.OnThink(); - - if (ShouldStop()) + if (Core.TickCount - _nextThink >= 0) { - Stop(); - return; + _owner.Mobile.OnThink(); + + if (ShouldStop()) + { + Stop(); + return; + } + + HandleBardEffects(); + + if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think()) + { + HandleDetectHidden(); + } + + // Cadence from the post-decision speed (decisions may flip active/passive). + _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + } + else + { + _owner.ContinueMove(); } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); - HandleBardEffects(); + ScheduleNext(); + } - if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) + private void ScheduleNext() + { + var now = Core.TickCount; + var delay = _nextThink - now; + + if (_owner.TryGetMoveWake(out var nextMove)) { - return; + var moveDelay = nextMove - now; + + // Only a future budget is a wake — a blocked creature must not spin the timer. + if (moveDelay > 0 && moveDelay < delay) + { + delay = moveDelay; + } } - HandleDetectHidden(); + // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. + Interval = TimeSpan.FromMilliseconds(delay); } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 4b872ee79..cf64cae17 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -40,6 +40,7 @@ public abstract partial class BaseAI private Mobile _lkpTarget; private Point3D _lkpLocation; private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower private long _lkpExpireTick; private long _guardStopTick; private long _investigateStopTick; @@ -58,6 +59,7 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; + NextMove = Core.TickCount; AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) @@ -295,6 +297,9 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { + // A change of course invalidates between-think movement continuation. + ClearMoveIntent(); + switch (Action) { case ActionType.Wander: @@ -623,6 +628,7 @@ public abstract partial class BaseAI if (target == null) { + _herdGoal = null; return false; } @@ -630,7 +636,15 @@ public abstract partial class BaseAI if (distance >= 1 && distance <= 15) { - DoMove(Mobile.GetDirectionTo(target)); + // A cached boxed goal keeps the PathFollower persistent across ticks; walking + // through MoveToPoint paces herding on the movement clock and paths around + // obstacles. + if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y) + { + _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); + } + + MoveToPoint(_herdGoal, false); return true; } @@ -640,6 +654,7 @@ public abstract partial class BaseAI } Mobile.TargetLocation = null; + _herdGoal = null; return false; } @@ -1131,6 +1146,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); + AITimer.OnSpeedChanged(); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index d1a0c7faf..f64a289f4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,8 +265,12 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; - // Herding - Overrides the AI to force the mob to move to a specific location - // Thinking: 0.3s, Movement: 0.6s. + // Movement clock (seconds per step); 0 = inherit the matching think value. + private double _activeMoveSpeed; + private double _passiveMoveSpeed; + + // Herding - forces the mob to walk to a specific location, paced by the movement + // clock at HerdingMoveSpeed. Thinking is unaffected. private IPoint2D _targetLocation; private int m_DamageMax = -1; @@ -342,6 +346,7 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -673,6 +678,7 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int RangeHome { get; set; } = 10; + /// Seconds per AI decision while engaged; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double ActiveSpeed { @@ -686,6 +692,7 @@ namespace Server.Mobiles } } + /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -700,21 +707,37 @@ namespace Server.Mobiles } } + /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. + [CommandProperty(AccessLevel.GameMaster)] + public virtual double ActiveMoveSpeed + { + get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; + set => _activeMoveSpeed = value > 0 ? value : 0; + } + + /// Seconds per step while idle. Inherits ; set 0 to re-inherit. + [CommandProperty(AccessLevel.GameMaster)] + public virtual double PassiveMoveSpeed + { + get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + set => _passiveMoveSpeed = value > 0 ? value : 0; + } + + // Herded creatures walk at a fixed standard pace regardless of their own speed + // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). + private const double HerdingMoveSpeed = 0.3; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { get => _targetLocation; - set - { - _targetLocation = value; - AIObject?.OnCurrentSpeedChanged(); - } + set => _targetLocation = value; } [CommandProperty(AccessLevel.GameMaster)] public double CurrentSpeed { - get => _targetLocation != null ? 0.3 : _currentSpeed; + get => _currentSpeed; set { if (Math.Abs(_currentSpeed - value) > 0.0001) @@ -725,8 +748,26 @@ namespace Server.Mobiles } } + /// + /// Resolved seconds per step: a verbatim active/passive + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. + /// A herded creature is always driven at . + /// [CommandProperty(AccessLevel.GameMaster)] - public double MoveSpeedMod { get; set; } + public double CurrentMoveSpeed + { + get + { + if (_targetLocation != null) + { + return HerdingMoveSpeed; + } + + return _currentSpeed == _activeSpeed ? ActiveMoveSpeed + : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed + : _currentSpeed; + } + } [CommandProperty(AccessLevel.GameMaster)] public Point3D Home @@ -1850,7 +1891,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(21); // version + writer.Write(22); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1970,6 +2011,10 @@ namespace Server.Mobiles // Version 19 writer.Write(HomeMap); + + // Version 22 (0 = inherit the matching think value) + writer.Write(_activeMoveSpeed); + writer.Write(_passiveMoveSpeed); } public override void Deserialize(IGenericReader reader) @@ -2173,6 +2218,16 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } + if (version >= 22) + { + _activeMoveSpeed = reader.ReadDouble(); + _passiveMoveSpeed = reader.ReadDouble(); + } + else + { + MigrateMoveSpeeds(); + } + if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -4586,13 +4641,78 @@ namespace Server.Mobiles return false; } + /// + /// Sets the think clock and clears movement overrides (legacy one-clock semantics); + /// use for an independent movement pace. + /// public void SetSpeed(double active, double passive, bool isPassive = true) { ActiveSpeed = active; PassiveSpeed = passive; + ClearMoveSpeed(); CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed; } + /// Sets only the movement clock (seconds per step). + public void SetMoveSpeed(double active, double passive) + { + ActiveMoveSpeed = active; + PassiveMoveSpeed = passive; + } + + /// Clears movement overrides; steps pace off the think clock again. + public void ClearMoveSpeed() + { + _activeMoveSpeed = 0; + _passiveMoveSpeed = 0; + } + + /// + /// Scales movement overrides (paragon and similar buffs). Inheriting values stay + /// inheriting — they already follow the scaled think clock. + /// + public void ScaleMoveSpeed(double scalar) + { + if (_activeMoveSpeed > 0) + { + _activeMoveSpeed *= scalar; + } + + if (_passiveMoveSpeed > 0) + { + _passiveMoveSpeed *= scalar; + } + } + + /// + /// Snaps speeds within rounding distance of the creature's table values back to + /// exact. A scaling buff that divides then multiplies can drift by an ulp (e.g. + /// 0.9 and 0.45 through 1.2), which would read as hand-tuned; call after undoing + /// such a buff. Genuinely tuned speeds are nowhere near the epsilon and keep. + /// + public void SnapSpeedsToTable() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (Math.Abs(_activeSpeed - activeSpeed) < 0.0001 && Math.Abs(_passiveSpeed - passiveSpeed) < 0.0001) + { + _activeSpeed = activeSpeed; + _passiveSpeed = passiveSpeed; + } + + GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); + + if (activeMoveSpeed > 0 && Math.Abs(_activeMoveSpeed - activeMoveSpeed) < 0.0001) + { + _activeMoveSpeed = activeMoveSpeed; + } + + if (passiveMoveSpeed > 0 && Math.Abs(_passiveMoveSpeed - passiveMoveSpeed) < 0.0001) + { + _passiveMoveSpeed = passiveMoveSpeed; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed; @@ -4906,6 +5026,25 @@ namespace Server.Mobiles NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); } + public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + } + + // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds + // still match what it would spawn with today was never hand-tuned: adopt today's + // move values so existing worlds (and pets) pick up npc-speeds pacing without a + // respawn. Tuned creatures keep movement inheriting their think clock. + internal void MigrateMoveSpeeds() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (_activeSpeed == activeSpeed && _passiveSpeed == passiveSpeed) + { + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + } + } + public virtual void DropBackpack() { var backpack = Backpack; diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index a61ce70c6..8f5e908bb 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,6 +38,22 @@ public static class NPCSpeeds passiveSpeed = sp.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. + public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) + { + if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && + !_speedsByType.TryGetValue(bc.GetType(), out sp) && + !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) + { + activeMoveSpeed = 0; + passiveMoveSpeed = 0; + return; + } + + activeMoveSpeed = sp.ActiveMoveSpeed; + passiveMoveSpeed = sp.PassiveMoveSpeed; + } + public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry; @@ -78,6 +94,13 @@ public static class NPCSpeeds [JsonPropertyName("passive")] public double PassiveSpeed { get; init; } + // Movement clock (seconds per step); absent/0 = inherit the matching think value. + [JsonPropertyName("activeMove")] + public double ActiveMoveSpeed { get; init; } + + [JsonPropertyName("passiveMove")] + public double PassiveMoveSpeed { get; init; } + [JsonPropertyName("types")] public HashSet Types { get; init; } } diff --git a/Projects/UOContent/Mobiles/Special/Paragon.cs b/Projects/UOContent/Mobiles/Special/Paragon.cs index 825609e22..cbd0a30c7 100644 --- a/Projects/UOContent/Mobiles/Special/Paragon.cs +++ b/Projects/UOContent/Mobiles/Special/Paragon.cs @@ -79,6 +79,7 @@ public static class Paragon bc.PassiveSpeed /= SpeedBuff; bc.ActiveSpeed /= SpeedBuff; + bc.ScaleMoveSpeed(1.0 / SpeedBuff); bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin += DamageBuff; @@ -143,6 +144,8 @@ public static class Paragon bc.PassiveSpeed *= SpeedBuff; bc.ActiveSpeed *= SpeedBuff; + bc.ScaleMoveSpeed(SpeedBuff); + bc.SnapSpeedsToTable(); // an ulp of scaling drift must not read as hand-tuned bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin -= DamageBuff; diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 12c9515ef..ce7f18acf 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,6 +23,12 @@ description: > 4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`** 5. **No LINQ** in game logic -- use loops and `PooledRefList` 6. **File placement** matters -- follow the directory conventions below +7. **Creature speeds are delays in seconds, on two clocks** -- think + (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move + (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until + overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think + AND clears move overrides, `SetMoveSpeed()` sets move only -- see + `dev-docs/content-patterns.md` § Creature Speeds ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 30bb942f3..ec56e1bb3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,6 +254,35 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` +### Creature Speeds (think vs move clocks) + +All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: + +- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision + (combat decisions, target acquisition, spell timing). +- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per + step. Inherits the matching think value until overridden, so a creature configured with + only think speeds behaves as one clock. Any value is legal — steps are scheduled + independently of think ticks, so the two need not divide evenly. + +Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type +lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: + +```csharp +public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json +``` + +Code-level overrides for special cases: + +```csharp +SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics) +SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted +ClearMoveSpeed(); // back to inheriting the think clock +``` + +All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance +move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). + --- ## New Spell From 38c74a968b5935a4a44c5e8265696add1009f520 Mon Sep 17 00:00:00 2001 From: Tald0r <47738492+Tald0r@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:51:42 +0200 Subject: [PATCH 56/64] fix(regions): correct end Z coordinate assignment in InitRectangles (#2597) The `ez` variable was incorrectly assigned `rect.End.X` instead of `rect.End.Z`, causing incorrect rectangle processing in region initialization. --- Projects/UOContent/Regions/BaseRegion.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index b18c7fa18..776ec269d 100644 --- a/Projects/UOContent/Regions/BaseRegion.cs +++ b/Projects/UOContent/Regions/BaseRegion.cs @@ -113,7 +113,7 @@ public class BaseRegion : Region m_RectBuffer2.RemoveAt(k); var sz = rect.Start.Z; - var ez = rect.End.X; + var ez = rect.End.Z; if (l1 < l2) { From 4420872b22bd9301225335a472ab485941898820 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:39:29 -0700 Subject: [PATCH 57/64] fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2593. Closes #2595. Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues. ## #2593 — pets follow slowly; stale AITimer wakes **Why pets slowed:** - The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**. - Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step. - The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow. **What changed:** - **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant. - **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers. The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation. ## #2595 — Guard order converts to Attack during combat **Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish. **What changed:** - **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight. - **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message. - **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active. - **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds. ## Resulting behavior (Medium-bucket pet) | Scenario | Broken | This PR | |---|---|---| | Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes | | Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master | | `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over | | Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) | | Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn | | Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) | | Wild creature chase | RunUO-parity move table | unchanged | Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract). ## Testing - Full suite passes (1570: 837 Server + 733 UOContent). - `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly. - `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain. - `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS. - All behavioral tests were written first and failed for the documented reasons. --- .../Tests/Mobiles/AI/GuardFollowTests.cs | 96 ++++++++ .../Tests/Mobiles/AI/GuardOrderTests.cs | 137 +++++++++++ .../Tests/Mobiles/AI/PetPacingTests.cs | 222 ++++++++++++++++++ .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 48 ++-- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 70 +++++- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 2 +- .../Mobiles/AI/BaseAI/PetOrderHandlers.cs | 24 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 117 +++++---- Projects/UOContent/Mobiles/BaseCreature.cs | 7 +- .../modernuo-content-patterns.md | 7 + dev-docs/content-patterns.md | 55 +++++ 11 files changed, 712 insertions(+), 73 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs new file mode 100644 index 000000000..db5225359 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardFollowTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Guard-following may pathfind, so this shares the pathfinding collection. +[Collection("Sequential Pathfinding Tests")] +public class GuardFollowTests +{ + [Fact] + public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); // the order change may restart the timer + + var start = pet.Location; + ai.NextMove = 0; + ai.Obey(); + + var moved = pet.Location != start; + var hasIntent = ai.TryGetMoveWake(out _); + var currentSpeed = pet.CurrentSpeed; + var currentMoveSpeed = pet.CurrentMoveSpeed; + + pet.Delete(); + master.Delete(); + + Assert.True(moved, "a guarding pet beyond guard range must step toward its master"); + // Without a move intent, guard-following only steps on the think grid. + Assert.True(hasIntent, "guard-following must register a move intent"); + + // AOS return sprint on both clocks; the per-step speed flip must not undo it. + Assert.Equal(0.1, currentSpeed); + Assert.Equal(0.1, currentMoveSpeed); + } + + [Fact] + public void GuardReturn_PreAOS_RunsActive() + { + var previous = Core.Expansion; + + try + { + Core.Expansion = Expansion.UOR; + + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + + var ai = pet.AIObject; + ai.AITimer?.Stop(); + pet.ControlOrder = OrderType.Guard; + ai.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist + + ai.NextMove = 0; + ai.Obey(); + + var currentSpeed = pet.CurrentSpeed; + + pet.Delete(); + master.Delete(); + + // No sprint pre-AOS: the return runs active. + Assert.Equal(0.2, currentSpeed); + } + finally + { + Core.Expansion = previous; + } + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs new file mode 100644 index 000000000..7d572786a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/GuardOrderTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// A guarding pet fights without leaving the Guard order, retargets toward the master's +// closest aggressor, and stands down when nothing threatens. Scene: the open +// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs. +[Collection("Sequential UOContent Tests")] +public class GuardOrderTests : IDisposable +{ + private readonly List _created = new(); + + private sealed class AggressorStub : Mobile + { + public AggressorStub() => Body = 0xC9; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z) + { + map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out z, out _); + + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + _created.Add(master); + + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map); + pet.SetControlMaster(master); + _created.Add(pet); + + pet.AIObject.AITimer?.Stop(); // drive manually + pet.ControlOrder = OrderType.Guard; + pet.AIObject.AITimer?.Stop(); // the order change restarts the timer + + return (master, pet); + } + + private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking) + { + var aggr = new AggressorStub(); + aggr.MoveToWorld(loc, pet.Map); + _created.Add(aggr); + + // Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed. + Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}"); + + if (attacking != null) + { + aggr.Combatant = attacking; + Assert.Same(attacking, aggr.Combatant); + } + + return aggr; + } + + [Fact] + public void GuardEngage_KeepsGuardOrder() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); + + pet.AIObject.Obey(); + + Assert.Same(aggr, pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder); + } + + [Fact] + public void Guard_RetargetsToAggressorClosestToMaster() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master); + var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master); + + pet.Combatant = far; // already fighting the far aggressor + + pet.AIObject.Obey(); + + Assert.Same(near, pet.Combatant); // defends the master, not the current fight + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [Fact] + public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack() + { + var (master, pet) = SpawnGuardingPet(out _, out var z); + + // Explicit kill order on a target that then becomes invalid. + var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null); + pet.ControlTarget = victim; + pet.ControlOrder = OrderType.Attack; + victim.Hidden = true; + + // A second aggressor is still after the master; FightMode.Closest would chain it. + var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master); + + pet.AIObject.Obey(); // attack completes -> resume the persistent Guard + + Assert.Equal(OrderType.Guard, pet.ControlOrder); + + pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order + + Assert.Same(aggr2, pet.Combatant); + Assert.Equal(OrderType.Guard, pet.ControlOrder); + } + + [Fact] + public void PeacefulGuard_StandsDown() + { + var (_, pet) = SpawnGuardingPet(out _, out _); + Assert.True(pet.Warmode); // the guard order opens in war stance + + pet.AIObject.Obey(); // nothing to guard against + + Assert.False(pet.Warmode); + Assert.Null(pet.Combatant); + Assert.Null(pet.FocusMob); + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs new file mode 100644 index 000000000..217049b3d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/PetPacingTests.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing. +[Collection("Sequential UOContent Tests")] +public class PetPacingTests : IDisposable +{ + private readonly List _created = new(); + + private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc) + { + var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc); + _created.Add(pair.master); + _created.Add(pair.pet); + return pair; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + // Movement orders run active, resting orders run passive; the move clock follows. + [Fact] + public void OrderIssue_SetsThinkClock() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.ControlOrder = OrderType.Come; + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove + + pet.ControlOrder = OrderType.Stay; + Assert.Equal(0.4, pet.CurrentSpeed); + Assert.Equal(0.9, pet.CurrentMoveSpeed); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + Assert.Equal(0.2, pet.CurrentSpeed); + + pet.ControlOrder = OrderType.Guard; + Assert.Equal(0.2, pet.CurrentSpeed); + } + + // AOS: following the master sprints at a bespoke 0.1 on both clocks. + [Fact] + public void FollowMaster_ObeySprints() + { + var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; // fixture era is EJ + pet.AIObject.Obey(); + + Assert.Equal(0.1, pet.CurrentSpeed); + Assert.Equal(0.1, pet.CurrentMoveSpeed); + } + + // At the master's side a guarding pet stays active: no stale-warmode passive, no sprint. + [Fact] + public void GuardAtMastersSide_IsActive() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.3, 0.9); + pet.AIObject.AITimer?.Stop(); + pet.SetCurrentSpeedToPassive(); + + pet.ControlOrder = OrderType.Guard; + pet.AIObject.Obey(); // nothing to guard against, master adjacent + + Assert.Equal(0.2, pet.CurrentSpeed); + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + + // A pet chasing a combatant keeps the move table. + [Fact] + public void CombatChasingPet_KeepsMoveTable() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + var target = new PetTestStub(); + target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca); + _created.Add(target); + + pet.SetMoveSpeed(0.3, 0.9); + pet.ControlOrder = OrderType.Guard; + pet.Combatant = target; + pet.SetCurrentSpeedToActive(); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); + } + + // Herding overrides order pacing. + [Fact] + public void HerdedObeyingPet_KeepsHerdingPace() + { + var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0)); + pet.SetMoveSpeed(0.45, 0.9); + pet.SetCurrentSpeedToPassive(); + + pet.TargetLocation = new Point2D(1010, 1010); + + Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace + } + + private sealed class ThinkProbe : PetTestStub + { + public int Thinks; + + public override void OnThink() + { + Thinks++; + base.OnThink(); + } + } + + private (PlayerMobile master, ThinkProbe pet) SpawnProbe() + { + var master = new PlayerMobile(World.NewMobile); + master.DefaultMobileInit(); + master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + _created.Add(master); + + var pet = new ThinkProbe(); + pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca); + pet.SetControlMaster(master); + _created.Add(pet); + + return (master, pet); + } + + // Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync. + private static void RunFor(long ms) + { + var deadline = Core._tickCount + ms; + + while (Core._tickCount < deadline) + { + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + } + + private static bool RunUntil(Func condition, long maxMs) + { + var deadline = Core._tickCount + maxMs; + + while (Core._tickCount < deadline) + { + if (condition()) + { + return true; + } + + Core._tickCount += 8; + Timer.Slice(Core._tickCount); + } + + return condition(); + } + + // Runs past the spawn stagger; returns right after a think with the next 0.4s away. + private ThinkProbe SettledProbe(out PlayerMobile master) + { + Core._tickCount = 0; + Timer.Init(0); + + var (m, pet) = SpawnProbe(); + master = m; + pet.ForceIdle = true; // no wandering; pure cadence + pet.ControlOrder = OrderType.Stay; + + var settled = RunUntil(() => pet.Thinks >= 2, 8000); + Assert.True(settled, "the AI must reach a steady think cadence"); + + return pet; + } + + [Fact] + public void OrderChange_WakesStaleThinkTimer() + { + var pet = SettledProbe(out var master); + var thinksBefore = pet.Thinks; + + RunFor(200); // mid-wait, next think ~200ms out + Assert.Equal(thinksBefore, pet.Thinks); + + pet.ControlTarget = master; + pet.ControlOrder = OrderType.Follow; + + RunFor(80); + Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly"); + } + + [Fact] + public void SpeedUp_ReschedulesPendingWake() + { + var pet = SettledProbe(out _); + var thinksBefore = pet.Thinks; + + RunFor(200); // mid-wait, next think ~200ms out + Assert.Equal(thinksBefore, pet.Thinks); + + pet.CurrentSpeed = 0.1; + + RunFor(120); + Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake"); + } +} diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index ccd1c72b6..5095a4cb4 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -118,18 +118,17 @@ public abstract partial class BaseAI if (TryMove(d)) { - // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. - if (Core.AOS && IsFollowingMaster()) + // Obeying pets are paced by their order handlers. + if (!IsObeyingMoveOrder()) { - Mobile.CurrentSpeed = 0.1; - } - else if (Mobile.Warmode || Mobile.Combatant != null) - { - Mobile.SetCurrentSpeedToActive(); - } - else - { - Mobile.SetCurrentSpeedToPassive(); + if (Mobile.Warmode || Mobile.Combatant != null) + { + Mobile.SetCurrentSpeedToActive(); + } + else + { + Mobile.SetCurrentSpeedToPassive(); + } } ConsumeMoveBudget(); @@ -541,8 +540,7 @@ public abstract partial class BaseAI { nextMove = NextMove; - return (_moveIntentTarget != null || _moveIntentPoint != null) && - Core.TickCount - _moveIntentExpire < 0; + return (_moveIntentTarget != null || _moveIntentPoint != null) && Core.TickCount - _moveIntentExpire < 0; } /// @@ -574,9 +572,9 @@ public abstract partial class BaseAI } var distance = (int)Mobile.GetDistanceToSqrt(m); - var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 5; - - var shouldRun = run && distance > distanceThreshold; + //TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter + var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3; + var shouldRun = distance > distanceThreshold; if (Mobile.InRange(m, range)) { @@ -599,6 +597,13 @@ public abstract partial class BaseAI Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null; + // A pet executing a movement order outside combat; its order handler owns its speed. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsObeyingMoveOrder() => + Mobile.Controlled && + Mobile.Combatant == null && + Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; + private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) { var distance = (int)Mobile.GetDistanceToSqrt(target); @@ -649,14 +654,12 @@ public abstract partial class BaseAI { var iCurrDist = (int)Mobile.GetDistanceToSqrt(m); - var shouldRun = run && iCurrDist > 5; - if (iCurrDist >= iWantDistMin && iCurrDist <= iWantDistMax) { return true; } - if (!MoveTowardsOrAwayFrom(m, shouldRun, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) { return false; } @@ -667,18 +670,17 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } + // run only sets the client animation; callers gate it on their own distance thresholds. private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) { - var shouldRun = run && iCurrDist > 5; - if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, shouldRun, iWantDistMax); + return ApproachTarget(m, run, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, shouldRun), true)) + if (DoMove(m.GetDirectionTo(Mobile, run), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index fe24e5f6d..d5a84f94d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -26,6 +26,8 @@ public sealed class AITimer : Timer { private readonly BaseAI _owner; private long _nextThink; + private long _nextWake; // when the pending wheel entry fires + private bool _inTick; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -40,8 +42,30 @@ public sealed class AITimer : Timer public void Activate() { _nextThink = Core.TickCount; - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + + if (Running) + { + return; + } + + Start(); // keeps the stagger Delay + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; + } + + // Think now. A think grants no action: steps, swings, casts, and abilities keep their own gates. + public void Prod() + { + _nextThink = Core.TickCount; + + if (Running) + { + Reschedule(); + return; + } + + Delay = TimeSpan.Zero; Start(); + _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } // A speed-up must not wait out a stale, longer think deadline. @@ -52,12 +76,53 @@ public sealed class AITimer : Timer if (candidate - _nextThink < 0) { _nextThink = candidate; + Reschedule(); + } + } + + // Moves the pending wake earlier. Interval is only read after the next fire, + // so this needs Stop, Delay = remaining, Start. + private void Reschedule() + { + if (_inTick || !Running) + { + return; // ScheduleNext handles it at tick end } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + var now = Core.TickCount; + var deadline = _nextThink; + + if (_owner.TryGetMoveWake(out var nextMove) && nextMove - now > 0 && nextMove - deadline < 0) + { + deadline = nextMove; + } + + if (deadline - _nextWake >= 0) + { + return; // pending wake is already early enough + } + + Stop(); + Delay = TimeSpan.FromMilliseconds(Math.Max(0, deadline - now)); + Start(); + _nextWake = now + (long)Delay.TotalMilliseconds; } protected override void OnTick() + { + _inTick = true; + + try + { + OnTickCore(); + } + finally + { + _inTick = false; + } + } + + private void OnTickCore() { if (ShouldStop()) { @@ -111,6 +176,7 @@ public sealed class AITimer : Timer // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. Interval = TimeSpan.FromMilliseconds(delay); + _nextWake = now + (long)Interval.TotalMilliseconds; } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index cf64cae17..f86ac2bfe 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -64,7 +64,7 @@ public abstract partial class BaseAI if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) { - AITimer.Start(); + AITimer.Activate(); } if (Action != ActionType.Wander) diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs index ecb456d2e..862c78649 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrderHandlers.cs @@ -26,7 +26,7 @@ public abstract partial class BaseAI return; } - Activate(); + AITimer.Prod(); switch (Mobile.ControlOrder) { @@ -36,6 +36,10 @@ public abstract partial class BaseAI break; } case OrderType.Come: + { + Mobile.SetCurrentSpeedToActive(); + break; + } case OrderType.Drop: case OrderType.Friend: case OrderType.Unfriend: @@ -135,6 +139,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); } private void HandleTransferOrder() @@ -148,6 +153,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -162,9 +168,16 @@ public abstract partial class BaseAI _commandIssuer?.RevealingAction(); Mobile.FocusMob = null; Mobile.Warmode = true; - Mobile.PlaySound(Mobile.GetAttackSound()); - Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); - // ~1_NAME~ is now guarding you. + Mobile.SetCurrentSpeedToActive(); + + // Resuming the persistent order must not replay the flourish. + if (!_resolvingOrder) + { + Mobile.PlaySound(Mobile.GetAttackSound()); + Mobile.ControlMaster?.SendLocalizedMessage(1049671, Mobile.Name); + // ~1_NAME~ is now guarding you. + } + _commandIssuer = null; } @@ -191,6 +204,7 @@ public abstract partial class BaseAI } Mobile.Warmode = true; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetAttackSound()); _commandIssuer = null; } @@ -206,6 +220,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToActive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; } @@ -221,6 +236,7 @@ public abstract partial class BaseAI Mobile.FocusMob = null; Mobile.Warmode = false; Mobile.Combatant = null; + Mobile.SetCurrentSpeedToPassive(); Mobile.PlaySound(Mobile.GetIdleSound()); _commandIssuer = null; // Home (the stay anchor) is owned by SetPersistentOrder, not this handler. diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 1b7139a96..54dd5774f 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -128,6 +128,12 @@ public abstract partial class BaseAI this.DebugSayFormatted($"I am ordered to follow {Mobile.ControlTarget?.Name}."); + // AOS: sprint after the master (bespoke 0.1 paces both clocks). + if (Core.AOS && Mobile.ControlTarget == Mobile.ControlMaster && Mobile.Combatant == null) + { + Mobile.CurrentSpeed = 0.1; + } + if (currentDistance > 1) { WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); @@ -291,14 +297,13 @@ public abstract partial class BaseAI return true; } - FindCombatant(); + var combatant = FindGuardTarget(); - if (IsValidCombatant(Mobile.Combatant)) + if (combatant != null) { - var combatant = Mobile.Combatant; - this.DebugSayFormatted($"Attacking target: {combatant.Name}"); + // Engage without leaving the Guard order so tags, recall handling, and retargeting persist. Mobile.Combatant = combatant; Mobile.FocusMob = combatant; Action = ActionType.Combat; @@ -309,16 +314,30 @@ public abstract partial class BaseAI { this.DebugSayFormatted($"Guarding my master, {controlMaster.Name}."); - var guardLocation = controlMaster.Location; + // Stand down; a stale Warmode would skew the return pace. + Mobile.FocusMob = null; + Mobile.Warmode = false; + Mobile.Combatant = null; - var distance = (int)Mobile.GetDistanceToSqrt(guardLocation); + var distance = (int)Mobile.GetDistanceToSqrt(controlMaster); if (distance > 3) { - DoMove(Mobile.GetDirectionTo(guardLocation)); + // AOS: sprint back (bespoke 0.1 paces both clocks); earlier eras run active. + if (Core.AOS) + { + Mobile.CurrentSpeed = 0.1; + } + else + { + Mobile.SetCurrentSpeedToActive(); + } + + WalkMobileRange(controlMaster, 1, true, 1, 3); } else { + Mobile.SetCurrentSpeedToActive(); // alert at the master's side WalkRandom(3, 1, 1); } } @@ -359,67 +378,83 @@ public abstract partial class BaseAI Mobile.ControlTarget = Mobile.ControlMaster; ResumePersistentOrder(); - if (Mobile.FightMode is FightMode.Closest or FightMode.Aggressor) + // A resumed Guard engages through its own scan; other fallbacks chain an explicit Attack. + if (Mobile.ControlOrder == OrderType.Guard || + Mobile.FightMode is not (FightMode.Closest or FightMode.Aggressor)) { - FindCombatant(); + return; + } + + var next = FindGuardTarget(); + + if (next != null) + { + Mobile.ControlTarget = next; + Mobile.ControlOrder = OrderType.Attack; + Mobile.Combatant = next; + + this.DebugSayFormatted($"{next.Name} is still hostile! Engaging..."); + + Think(); } } - private void FindCombatant() + /// + /// Selects the aggressor closest to the master. The current combatant is kept + /// unless a strictly closer one exists. Never mutates order state. + /// + private Mobile FindGuardTarget() { var controlMaster = Mobile.ControlMaster; + var anchor = controlMaster ?? Mobile; + + var current = Mobile.Combatant; + var best = current != controlMaster && IsValidCombatant(current) ? current : null; + var bestDist = best?.GetDistanceToSqrt(anchor) ?? double.MaxValue; foreach (var aggr in Mobile.GetMobilesInRange(Mobile.RangePerception)) { - if (!Mobile.CanSee(aggr) || aggr.IsDeadBondedPet || !aggr.Alive) + if (aggr == best || aggr == Mobile || aggr == controlMaster || + aggr.IsDeadBondedPet || !aggr.Alive || + aggr.Combatant != Mobile && (controlMaster == null || aggr.Combatant != controlMaster)) { continue; } - var isAttackingPet = aggr.Combatant == Mobile; - var isAttackingMaster = controlMaster != null && aggr.Combatant == controlMaster; + var dist = aggr.GetDistanceToSqrt(anchor); - if (isAttackingPet || isAttackingMaster) + if (dist < bestDist && Mobile.CanSee(aggr) && Mobile.InLOS(aggr)) { - if (Mobile.InLOS(aggr)) - { - Mobile.ControlTarget = aggr; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggr; - - var target = isAttackingMaster ? "master" : "me"; - this.DebugSayFormatted($"{aggr.Name} is attacking my {target}! Engaging..."); - - Think(); - return; - } + best = aggr; + bestDist = dist; } } - if (controlMaster?.Aggressors != null) - { - for (var i = 0; i < controlMaster.Aggressors.Count; i++) - { - var aggressor = controlMaster.Aggressors[i].Attacker; + var aggressors = controlMaster?.Aggressors; - if (aggressor?.Deleted != false || !aggressor.Alive || aggressor.IsDeadBondedPet) + if (aggressors != null) + { + for (var i = 0; i < aggressors.Count; i++) + { + var aggressor = aggressors[i].Attacker; + + if (aggressor == best || aggressor?.Deleted != false || !aggressor.Alive || + aggressor.IsDeadBondedPet || !Mobile.InRange(aggressor, Mobile.RangePerception)) { continue; } - if (Mobile.InRange(aggressor, Mobile.RangePerception) && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) + var dist = aggressor.GetDistanceToSqrt(anchor); + + if (dist < bestDist && Mobile.CanSee(aggressor) && Mobile.InLOS(aggressor)) { - Mobile.ControlTarget = aggressor; - Mobile.ControlOrder = OrderType.Attack; - Mobile.Combatant = aggressor; - - this.DebugSayFormatted($"{aggressor.Name} recently attacked my master! Retaliating..."); - - Think(); - return; + best = aggressor; + bestDist = dist; } } } + + return best; } public virtual bool DoOrderRelease() diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f64a289f4..15d9b4c03 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -750,8 +750,9 @@ namespace Server.Mobiles /// /// Resolved seconds per step: a verbatim active/passive - /// maps to the matching movement value; a bespoke pace stays fused to both clocks. - /// A herded creature is always driven at . + /// maps to the matching movement value; a bespoke pace (e.g. the pet-order 0.1 sprint) + /// stays fused to both clocks. A herded creature is always driven at + /// . /// [CommandProperty(AccessLevel.GameMaster)] public double CurrentMoveSpeed @@ -845,6 +846,8 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public Point3D ControlDest { get; set; } + // Fires on every assignment, not only changes: a reissued order is a command + // (retarget, break off combat, re-anchor Home). Handlers receive the previous order. [CommandProperty(AccessLevel.GameMaster)] public OrderType ControlOrder { diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index ce7f18acf..cdb3f9e49 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,6 +29,13 @@ description: > overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think AND clears move overrides, `SetMoveSpeed()` sets move only -- see `dev-docs/content-patterns.md` § Creature Speeds +8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the + think cadence (player commands prod it; speed-ups reschedule it). Gate consequential + work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call + random rolls are cosmetics-only. `MonsterAbility` is under the same contract: the + trigger cooldown is the rate limit, `ChanceToTrigger` is per-sample jitter, and a + zero-cooldown `Think`/`CombatAction` ability triggers every sampled think -- see + `dev-docs/content-patterns.md` § OnThink: the excess-call contract ## New Item Template diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index ec56e1bb3..80d925580 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,61 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). +### OnThink: the excess-call contract + +`OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the +think cadence (`CurrentSpeed`), but it can and does fire more often: a player command +wakes the AI immediately (`AITimer.Prod()`), a speed-up reschedules the pending wake, and +players run command macros that drive extra thinks deliberately (order spam is spam-safe +by design — reaction, never action). RunUO had the same property (its timer restarted +with a random delay on every speed change), so this has never been a fixed-rate callback. + +**Every `OnThink` override must be excess-call tolerant.** An extra call must never grant +an extra action: + +- Gate consequential work on its own deadline field, compared in subtraction form + (`Core.TickCount - _nextX >= 0` — see `tick-counts.md`), or make it idempotent. +- Never pace a consequential action with a bare per-call `Utility.RandomDouble()` roll — + its frequency then scales with think rate, which players can influence. Per-call rolls + are acceptable only for pure cosmetics (idle animations, flavor sounds). +- The engine already gates the expensive things: steps (the `NextMove` budget), weapon + swings, spell casts, detect-hidden, and the base `BaseCreature.OnThink` actions (heal, + rummage, aura) all carry their own clocks. Follow that pattern. + +```csharp +private long _nextSpecial; + +public override void OnThink() +{ + base.OnThink(); + + if (Core.TickCount - _nextSpecial >= 0) + { + DoSpecial(); + _nextSpecial = Core.TickCount + 5000; // the real rate limit lives here + } +} +``` + +### MonsterAbility: same contract + +`MonsterAbility.CanTrigger` is sampled once per think for `Think`- and +`CombatAction`-triggered abilities, so abilities live under the same rule: + +- **`MinTriggerCooldown`/`MaxTriggerCooldown` is the real rate limit** — the floor holds + no matter how often thinks fire. Always give a triggered ability a real cooldown. +- **`ChanceToTrigger` is a per-sample roll**: above the cooldown floor, the expected + trigger delay shrinks as think rate rises. Treat the chance as flavor jitter, never as + the rate limiter, and keep cooldowns long relative to the think interval so the jitter + stays negligible (fire breath — chance 0.5, cooldown 30–45s — varies under 1% between + natural and spammed think rates). +- A **zero-cooldown ability records no cooldown at all** and triggers on every sampled + think that passes its chance — only ever correct for passive alteration hooks, never + for `Think`/`CombatAction` triggers. +- An ability that breaks pet orders (fear-style effects) must own its duration explicitly + (a hold state, or a "refuses orders until" deadline checked in the order handlers) — + pets react to re-issued commands immediately, so think latency is not a hold. + --- ## New Spell From e07416902afebad3affbbdc1fbbf8c4a097df4d9 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:48:52 -0700 Subject: [PATCH 58/64] feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces. ### Why The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk. The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal. ### What **Pace-derived run flag** - `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles. - `DoMoveImpl` stamps the bit; it is the single place the flag is set. - `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds. **Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces) - A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport. - Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift. - Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich). - Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests). ### Accepted trade-off Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930. ### Tests `RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green. --- .../Fixtures/TestServerInitializer.cs | 1 + .../Tests/Mobiles/AI/ApproachTargetTests.cs | 10 +- .../Tests/Mobiles/AI/RunFlagTests.cs | 162 ++++++++++++++++++ .../Factions/Mobiles/Guards/GuardAI.cs | 4 +- .../UOContent/Engines/Pathing/PathFollower.cs | 6 +- Projects/UOContent/Mobiles/AI/AnimalAI.cs | 2 +- Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../Mobiles/AI/BaseAI/AIGroupMovement.cs | 6 +- .../UOContent/Mobiles/AI/BaseAI/AIMovement.cs | 94 +++++----- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 8 +- .../UOContent/Mobiles/AI/BaseAI/PetOrders.cs | 6 +- Projects/UOContent/Mobiles/AI/BerserkAI.cs | 2 +- Projects/UOContent/Mobiles/AI/HealerAI.cs | 2 +- Projects/UOContent/Mobiles/AI/MageAI.cs | 10 +- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 2 +- Projects/UOContent/Mobiles/AI/PredatorAI.cs | 4 +- Projects/UOContent/Mobiles/AI/ThiefAI.cs | 2 +- Projects/UOContent/Mobiles/BaseCreature.cs | 2 +- .../Mobiles/Familiars/BaseFamiliar.cs | 2 +- .../Monsters/LBR/Meers/EnragedCreatures.cs | 2 +- .../UOContent/Spells/Ninjitsu/MirrorImage.cs | 5 +- .../migrate-items-mobiles.md | 1 + .../modernuo-content-patterns.md | 5 +- dev-docs/content-patterns.md | 12 ++ .../09-items-mobiles-creatures.md | 23 +++ .../runuo-migration-docs/11-api-reference.md | 3 + 26 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index a38af9c3d..5e4230b7c 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -103,6 +103,7 @@ internal static class TestServerInitializer // Registers the Accounts entity persistence; without it no test can construct an Account. Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); + Server.Movement.Movement.Configure(); MovementImpl.Configure(); PathFollower.Configure(); World.Load(); diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs index 923f4aff9..a952252a6 100644 --- a/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/ApproachTargetTests.cs @@ -40,7 +40,7 @@ public class ApproachTargetTests for (var i = 0; i < maxTicks; i++) { ai.NextMove = 0; - ai.WalkMobileRange(target, 1, false, 1, 2); + ai.WalkMobileRange(target, 1, 1, 2); if (bc.InRange(target, arriveDist)) { return true; @@ -123,7 +123,7 @@ public class ApproachTargetTests for (var i = 0; i < 200; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.InRange(target, 1)) { arrived = true; @@ -154,7 +154,7 @@ public class ApproachTargetTests for (var i = 0; i < 60; i++) { ai.NextMove = 0; - ai.MoveTo(target, true, 1); + ai.MoveTo(target, 1); // Target walks west every other tick for its first several steps, then stops, // so a same-speed chaser eventually closes the gap. @@ -214,7 +214,7 @@ public class ApproachTargetTests for (var i = 0; i < 120; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); } // After giving up, the creature must idle (not oscillate) while the goal is still. @@ -223,7 +223,7 @@ public class ApproachTargetTests for (var i = 0; i < 20; i++) { ai.NextMove = 0; - ai.MoveTo(target, false, 1); + ai.MoveTo(target, 1); if (bc.Location != idleStart) { stayedIdle = false; diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs new file mode 100644 index 000000000..a421aaba2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/RunFlagTests.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// The Running bit is derived from the step pace: a step shorter than the client's walk +// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run. +[Collection("Sequential Pathfinding Tests")] +public class RunFlagTests : System.IDisposable +{ + private readonly List _created = new(); + + private PetTestStub Spawn(double activeMove) + { + var pet = new PetTestStub(); + pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca); + pet.AIObject.AITimer?.Stop(); + pet.SetMoveSpeed(activeMove, activeMove * 3); + pet.SetCurrentSpeedToActive(); + pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise + _created.Add(pet); + return pet; + } + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + [Theory] + [InlineData(0.3, true)] + [InlineData(0.125, true)] + [InlineData(0.4, false)] + [InlineData(0.45, false)] + [InlineData(1.05, false)] + public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected) + { + var pet = Spawn(activeMove); + + Assert.Equal(activeMove, pet.CurrentMoveSpeed); + Assert.Equal(expected, pet.AIObject.ShouldRun()); + } + + [Theory] + [InlineData(0.3, false)] + [InlineData(0.15, true)] + public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected) + { + var pet = Spawn(activeMove); + pet.Flying = true; + + Assert.Equal(expected, pet.AIObject.ShouldRun()); + } + + [Fact] + public void BadlyHurt_SlowsBelowWalk_DropsToWalk() + { + var pet = Spawn(0.35); + Assert.True(pet.AIObject.ShouldRun()); + + // The hurt inflation is on the observed step pace, so the flag follows it. + pet.SetHits(100); + pet.Hits = 5; + pet.SetStam(100); + pet.Stam = 5; + + Assert.False(pet.AIObject.ShouldRun()); + } + + [Theory] + [InlineData(0.3, true)] + [InlineData(0.45, false)] + public void DoMove_StampsRunningBit(double activeMove, bool expected) + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(activeMove); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + var ai = pet.AIObject; + ai.NextMove = 0; + var start = pet.Location; + + Assert.True(ai.DoMove(Direction.West)); + Assert.NotEqual(start, pet.Location); + Assert.Equal(expected, (pet.Direction & Direction.Running) != 0); + } + + // An isolated step (after standing at least a walk interval) renders alone and darts + // if run-flagged, so it walks; continuing cadences and true sprinters keep the flag. + [Fact] + public void IsolatedStep_DropsToWalk() + { + var pet = Spawn(0.3); + pet.LastMoveTime = Core.TickCount - 1000; + + Assert.False(pet.AIObject.ShouldRun()); + } + + [Fact] + public void IsolatedStep_SprinterStillRuns() + { + var pet = Spawn(0.125); + pet.LastMoveTime = Core.TickCount - 1000; + + Assert.True(pet.AIObject.ShouldRun()); + } + + [Fact] + public void StallDoesNotBankCatchUpSteps() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(0.3); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + var ai = pet.AIObject; + ai.NextMove = Core.TickCount - 1000; + + Assert.True(ai.DoMove(Direction.West)); + + // A stall must restart the cadence at full pace: banked catch-up steps + // release as a burst the client renders as a sprint/teleport. + Assert.False(ai.CanMoveNow(out _)); + Assert.True(ai.NextMove - Core.TickCount > 250); + } + + [Fact] + public void LateStepDoesNotEarnAQuickerFollowUp() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var pet = Spawn(0.3); + pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + + pet.Warmode = true; // keep the active move clock through the step + + var ai = pet.AIObject; + // The step lands 200ms past the budget — under one period, the reactive + // mirroring case (think grid vs budget deadline misalignment). + ai.NextMove = Core.TickCount - 200; + + Assert.True(ai.DoMove(Direction.West)); + + // The debt must not be repaid: a sub-period catch-up step follows ~100ms + // behind and renders as a dart pair beside the player. + Assert.True(ai.NextMove - Core.TickCount > 250); + } +} diff --git a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs index 32e8566cb..f0fb2386f 100644 --- a/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs +++ b/Projects/UOContent/Engines/Factions/Mobiles/Guards/GuardAI.cs @@ -359,14 +359,14 @@ namespace Server.Factions { if (m_Mobile.InRange( m, 1 )) RunFrom( m ); - else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 )) + else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) OnFailedMove(); } else {*/ if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, true, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Engines/Pathing/PathFollower.cs b/Projects/UOContent/Engines/Pathing/PathFollower.cs index 3a0a56fa5..04aaf0148 100644 --- a/Projects/UOContent/Engines/Pathing/PathFollower.cs +++ b/Projects/UOContent/Engines/Pathing/PathFollower.cs @@ -83,7 +83,7 @@ public class PathFollower public static bool Check(Point3D loc, Point3D goal, int range) => Utility.InRange(loc, goal, range) && (range > 1 || (loc.Z - goal.Z).Abs() < 16); - public bool Follow(bool run, int range) + public bool Follow(int range) { var goal = GetGoalLocation(); Direction d; @@ -97,13 +97,13 @@ public class PathFollower if (!(Enabled && m_Path.Success)) { - d = m_From.GetDirectionTo(goal, run); + d = m_From.GetDirectionTo(goal); m_From.SetDirection(d); return Move(d) is MoveResult.Success or MoveResult.SuccessAutoTurn && Check(m_From.Location, goal, range); } - d = m_From.GetDirectionTo(m_Next, run); + d = m_From.GetDirectionTo(m_Next); m_From.SetDirection(d); var res = Move(d); diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index a9d410483..e5a1670e3 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -38,7 +38,7 @@ public class AnimalAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dedd914c9..23ce0549c 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -43,7 +43,7 @@ public class ArcherAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index 548fe0d54..4463bf4de 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -63,7 +63,7 @@ public abstract partial class BaseAI return crowding; } - public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) + public static bool MoveToWithGroup(BaseAI ai, Mobile target, int range) { if (Core.TickCount - _lastGroupUpdateTime > 1000) { @@ -79,7 +79,7 @@ public abstract partial class BaseAI if (optimalPosition == Point3D.Zero) { - return ai.MoveToWithCollisionAvoidance(target, run, range); + return ai.MoveToWithCollisionAvoidance(target, range); } _reservedPositions[mobile] = optimalPosition; @@ -99,7 +99,7 @@ public abstract partial class BaseAI } // A blocked or wall-slid step is not progress — route around the obstacle. - return ai.ApproachTarget(target, run, range); + return ai.ApproachTarget(target, range); } finally { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 5095a4cb4..8b2a16cf2 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -18,6 +18,7 @@ using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; using MoveImpl = Server.Movement.MovementImpl; +using Moves = Server.Movement.Movement; namespace Server.Mobiles; @@ -43,7 +44,6 @@ public abstract partial class BaseAI // live, the AITimer wakes at NextMove between think ticks to advance the step. private Mobile _moveIntentTarget; private IPoint3D _moveIntentPoint; - private bool _moveIntentRun; private int _moveIntentRange; private long _moveIntentExpire; @@ -74,23 +74,42 @@ public abstract partial class BaseAI return Core.TickCount - NextMove >= 0; } - // Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly - // regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step. - private void ConsumeMoveBudget() + // Seconds per step as the client observes it: the move clock plus the hurt inflation. + private double EffectiveStepDelay() { var stepDelay = Mobile.CurrentMoveSpeed; - if (!(Core.AOS && IsFollowingMaster())) + return Core.AOS && IsFollowingMaster() ? stepDelay : BadlyHurtMoveDelay(Mobile, stepDelay); + } + + // The Running bit only selects the client's per-step interpolation (walk 400ms / run + // 200ms on foot, 200/100 mounted). A step shorter than the walk time must run or the + // client falls behind and snaps — but an isolated step (after standing at least a walk + // interval) renders alone and darts if run-flagged, so it goes out as a walk. A true + // sprinter always runs: a walk-rendered first step would flood the client's queue. + public bool ShouldRun() + { + var mounted = Mobile.Mounted || Mobile.Flying; + var walkDelay = mounted ? Moves.WalkMountDelay : Moves.WalkFootDelay; + var pace = EffectiveStepDelay() * 1000; + + if (pace >= walkDelay) { - stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + return false; } - NextMove += Math.Max(50, (long)(stepDelay * 1000)); + var runDelay = mounted ? Moves.RunMountDelay : Moves.RunFootDelay; - if (Core.TickCount - NextMove > 0) - { - NextMove = Core.TickCount; - } + return pace < runDelay || Core.TickCount - Mobile.LastMoveTime < walkDelay; + } + + // One step per period, paced from the step just taken — no debt accrual: repaying a + // late step with a quicker follow-up puts two steps ~100ms apart, which renders as a + // dart. In continuous pursuit the move-wake lands within wheel resolution of this + // deadline, so the only cost is single-digit-ms drift per step. + private void ConsumeMoveBudget() + { + NextMove = Core.TickCount + Math.Max(50, (long)(EffectiveStepDelay() * 1000)); } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -108,6 +127,8 @@ public abstract partial class BaseAI return MoveResult.BadState; } + d = (d & Direction.Mask) | (ShouldRun() ? Direction.Running : 0); + if ((Mobile.Direction & Direction.Mask) != (d & Direction.Mask)) { Mobile.Direction = d; @@ -334,7 +355,7 @@ public abstract partial class BaseAI /// best-distance stall counter idles the creature if an in-range goal is genuinely /// unreachable, without ever abandoning a real chase or detour. /// - protected bool ApproachTarget(Mobile target, bool run, int range) + protected bool ApproachTarget(Mobile target, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { @@ -361,7 +382,7 @@ public abstract partial class BaseAI ResetApproach(); // target moved — try again fresh } - RenewMoveIntent(target, null, run, range); + RenewMoveIntent(target, null, range); // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An @@ -373,7 +394,7 @@ public abstract partial class BaseAI if (Path == null && Mobile.InLOS(target)) { var distBefore = Mobile.GetDistanceToSqrt(target); - var res = DoMoveImpl(Mobile.GetDirectionTo(target, run), true); + var res = DoMoveImpl(Mobile.GetDirectionTo(target), true); if (res == MoveResult.BadState) { @@ -402,7 +423,7 @@ public abstract partial class BaseAI var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(run, range)) + if (Path.Follow(range)) { ResetApproach(); return true; @@ -421,7 +442,7 @@ public abstract partial class BaseAI /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around /// obstacles. Returns false on arrival or when genuinely unable to make progress. /// - public bool MoveToPoint(IPoint3D goal, bool run) + public bool MoveToPoint(IPoint3D goal) { if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) { @@ -434,12 +455,12 @@ public abstract partial class BaseAI Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; } - RenewMoveIntent(null, goal, run, 1); + RenewMoveIntent(null, goal, 1); var couldMove = CanMoveNow(out _) && !IsInBadState(); var locBefore = Mobile.Location; - if (Path.Follow(run, 1)) + if (Path.Follow(1)) { Path = null; ClearMoveIntent(); @@ -515,11 +536,10 @@ public abstract partial class BaseAI _approachGaveUp = false; } - private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) + private void RenewMoveIntent(Mobile target, IPoint3D point, int range) { _moveIntentTarget = target; _moveIntentPoint = point; - _moveIntentRun = run; _moveIntentRange = range; // A live pursuit renews every think tick; unrenewed intent dies on its own. @@ -556,26 +576,21 @@ public abstract partial class BaseAI if (_moveIntentTarget != null) { - ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + ApproachTarget(_moveIntentTarget, _moveIntentRange); } else { - MoveToPoint(_moveIntentPoint, _moveIntentRun); + MoveToPoint(_moveIntentPoint); } } - public virtual bool MoveTo(Mobile m, bool run, int range) + public virtual bool MoveTo(Mobile m, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) { return false; } - var distance = (int)Mobile.GetDistanceToSqrt(m); - //TODO Derive the Running bit from CurrentMoveSpeed in DoMoveImpl and drop the run parameter - var distanceThreshold = Core.AOS && IsFollowingMaster() ? 1 : 3; - var shouldRun = distance > distanceThreshold; - if (Mobile.InRange(m, range)) { ResetApproach(); @@ -584,10 +599,10 @@ public abstract partial class BaseAI if (UseGroupMovement(m, range)) { - return MoveToWithGroup(this, m, shouldRun, range); + return MoveToWithGroup(this, m, range); } - return ApproachTarget(m, shouldRun, range); + return ApproachTarget(m, range); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -604,12 +619,8 @@ public abstract partial class BaseAI Mobile.Combatant == null && Mobile.ControlOrder is OrderType.Come or OrderType.Follow or OrderType.Guard; - private bool MoveToWithCollisionAvoidance(Mobile target, bool run, int range) + private bool MoveToWithCollisionAvoidance(Mobile target, int range) { - var distance = (int)Mobile.GetDistanceToSqrt(target); - - var shouldRun = run && distance > 5; - var direction = Mobile.GetDirectionTo(target); // Wall-slide auto-turns must not count as progress, or a creature pinned on @@ -640,10 +651,10 @@ public abstract partial class BaseAI // Tactical sidesteps exhausted — route around the obstacle via the centralized // approach primitive (persistent PathFollower, no oscillation). - return ApproachTarget(target, shouldRun, range); + return ApproachTarget(target, range); } - public virtual bool WalkMobileRange(Mobile m, int iSteps, bool run, int iWantDistMin, int iWantDistMax) + public virtual bool WalkMobileRange(Mobile m, int iSteps, int iWantDistMin, int iWantDistMax) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m == null) { @@ -659,7 +670,7 @@ public abstract partial class BaseAI return true; } - if (!MoveTowardsOrAwayFrom(m, run, iCurrDist, iWantDistMax)) + if (!MoveTowardsOrAwayFrom(m, iCurrDist, iWantDistMax)) { return false; } @@ -670,17 +681,16 @@ public abstract partial class BaseAI return dist >= iWantDistMin && dist <= iWantDistMax; } - // run only sets the client animation; callers gate it on their own distance thresholds. - private bool MoveTowardsOrAwayFrom(Mobile m, bool run, int iCurrDist, int iWantDistMax) + private bool MoveTowardsOrAwayFrom(Mobile m, int iCurrDist, int iWantDistMax) { if (iCurrDist > iWantDistMax) { // Too far: approach via the centralized progress-based primitive. - return ApproachTarget(m, run, iWantDistMax); + return ApproachTarget(m, iWantDistMax); } // Too close: back away. Retreat keeps the simple greedy behavior (out of scope). - if (DoMove(m.GetDirectionTo(Mobile, run), true)) + if (DoMove(m.GetDirectionTo(Mobile), true)) { Path = null; return true; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index f86ac2bfe..3c2a479e0 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -442,7 +442,7 @@ public abstract partial class BaseAI var master = Mobile.SummonMaster; if (master != null && master.Map == Mobile.Map && master.InRange(Mobile, Mobile.RangePerception)) { - MoveTo(master, false, 1); + MoveTo(master, 1); } } @@ -592,7 +592,7 @@ public abstract partial class BaseAI } _lkpGoal ??= _lkpLocation; - return MoveToPoint(_lkpGoal, false); + return MoveToPoint(_lkpGoal); } private void ClearLastKnown() @@ -644,7 +644,7 @@ public abstract partial class BaseAI _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); } - MoveToPoint(_herdGoal, false); + MoveToPoint(_herdGoal); return true; } @@ -798,7 +798,7 @@ public abstract partial class BaseAI { if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("I backed off to safety. Wandering..."); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs index 54dd5774f..e6c48850d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/PetOrders.cs @@ -84,7 +84,7 @@ public abstract partial class BaseAI return true; } - WalkMobileRange(Mobile.ControlMaster, 1, false, 1, 2); + WalkMobileRange(Mobile.ControlMaster, 1, 1, 2); if (Mobile.GetDistanceToSqrt(Mobile.ControlMaster) <= 2) { @@ -136,7 +136,7 @@ public abstract partial class BaseAI if (currentDistance > 1) { - WalkMobileRange(Mobile.ControlTarget, 1, currentDistance > 2, 1, 2); + WalkMobileRange(Mobile.ControlTarget, 1, 1, 2); } } @@ -333,7 +333,7 @@ public abstract partial class BaseAI Mobile.SetCurrentSpeedToActive(); } - WalkMobileRange(controlMaster, 1, true, 1, 3); + WalkMobileRange(controlMaster, 1, 1, 3); } else { diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index 4663ae8d0..ff00ec91d 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/HealerAI.cs b/Projects/UOContent/Mobiles/AI/HealerAI.cs index 2a1127b43..cc54c73c3 100644 --- a/Projects/UOContent/Mobiles/AI/HealerAI.cs +++ b/Projects/UOContent/Mobiles/AI/HealerAI.cs @@ -81,7 +81,7 @@ public class HealerAI : BaseAI return true; } - WalkMobileRange(Mobile.FocusMob, 1, false, 4, 7); + WalkMobileRange(Mobile.FocusMob, 1, 4, 7); // TODO: Should it be able to do this? if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, Mobile.Combatant)) diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 61be59a1f..1ed8cbdda 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -171,7 +171,7 @@ public class MageAI : BaseAI { if (!SmartAI) { - if (!MoveTo(m, false, Mobile.RangeFight)) + if (!MoveTo(m, Mobile.RangeFight)) { OnFailedMove(); } @@ -185,14 +185,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, false, 1)) + if (!MoveTo(m, 1)) { OnFailedMove(); } @@ -701,7 +701,7 @@ public class MageAI : BaseAI { DebugSay("I cannot see my target, moving to regain line of sight"); - if (!MoveTo(c, false, 1)) + if (!MoveTo(c, 1)) { OnFailedMove(); } @@ -1039,7 +1039,7 @@ public class MageAI : BaseAI // target can be invoked. if (!Mobile.InLOS(toTarget)) { - MoveTo(toTarget, true, 1); + MoveTo(toTarget, 1); } else { diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index 544770069..a71d83d5c 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -99,7 +99,7 @@ public class MeleeAI : BaseAI private bool AttemptMoveToCombatant(Mobile combatant) { - if (MoveTo(combatant, false, Mobile.RangeFight)) + if (MoveTo(combatant, Mobile.RangeFight)) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index 5e0e01520..b1ea3a638 100644 --- a/Projects/UOContent/Mobiles/AI/PredatorAI.cs +++ b/Projects/UOContent/Mobiles/AI/PredatorAI.cs @@ -41,7 +41,7 @@ public class PredatorAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { @@ -70,7 +70,7 @@ public class PredatorAI : BaseAI } else if (AcquireFocusMob(Mobile.RangePerception * 2, FightMode.Closest, true, false, true)) { - if (WalkMobileRange(Mobile.FocusMob, 1, false, Mobile.RangePerception, Mobile.RangePerception * 2)) + if (WalkMobileRange(Mobile.FocusMob, 1, Mobile.RangePerception, Mobile.RangePerception * 2)) { DebugSay("Well, here I am safe"); diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index 0fa37bbc9..a9209dfbb 100644 --- a/Projects/UOContent/Mobiles/AI/ThiefAI.cs +++ b/Projects/UOContent/Mobiles/AI/ThiefAI.cs @@ -43,7 +43,7 @@ public class ThiefAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I should be closer to {combatant.Name}"); } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 15d9b4c03..05c728cf4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2861,7 +2861,7 @@ namespace Server.Mobiles CanBeHarmful(m) && IsEnemy(m)) { Combatant = FocusMob = m; - AIObject?.MoveTo(m, true, 1); + AIObject?.MoveTo(m, 1); DoHarmful(m); } } diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index d3b119523..46d939f3b 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -92,7 +92,7 @@ public abstract partial class BaseFamiliar : BaseCreature Hidden = m_LastHidden = master.Hidden; } - if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, 1, 1) == true) { Warmode = master.Warmode; Combatant = master.Combatant; diff --git a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs index 91367a5f0..ce4f3526d 100644 --- a/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs +++ b/Projects/UOContent/Mobiles/Monsters/LBR/Meers/EnragedCreatures.cs @@ -108,7 +108,7 @@ namespace Server.Mobiles */ else if (!Combat(this)) { - AIObject?.MoveTo(SummonMaster, false, 5); + AIObject?.MoveTo(SummonMaster, 5); } /* On OSI, if the summon attacks a mobile, the summoner meer also diff --git a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs index 6d85b9d73..63788aa69 100644 --- a/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs +++ b/Projects/UOContent/Spells/Ninjitsu/MirrorImage.cs @@ -238,10 +238,7 @@ namespace Server.Mobiles if (master?.Map == Mobile.Map && master?.InRange(Mobile, Mobile.RangePerception) == true) { - var iCurrDist = (int)Mobile.GetDistanceToSqrt(master); - var bRun = iCurrDist > 5; - - WalkMobileRange(master, 2, bRun, 0, 1); + WalkMobileRange(master, 2, 0, 1); } else { diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index 8edf70a72..ce70ad26c 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -29,6 +29,7 @@ description: > - `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` -> `BaseCreature(AI, Fight)` (extra params default) - `Name = "text"` -> `public override string DefaultName => "text";` - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` +- AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index cdb3f9e49..2b6df7d55 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -27,8 +27,9 @@ description: > (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think - AND clears move overrides, `SetMoveSpeed()` sets move only -- see - `dev-docs/content-patterns.md` § Creature Speeds + AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is + derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- + see `dev-docs/content-patterns.md` § Creature Speeds 8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the think cadence (player commands prod it; speed-ups reschedule it). Gate consequential work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 80d925580..3d11efb16 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -283,6 +283,18 @@ ClearMoveSpeed(); // back to inheriting the think clock All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). +The client's `Running` bit is derived from the step pace, never passed by callers +(`BaseAI.ShouldRun`, stamped in `DoMoveImpl`): a step shorter than the client's walk +interpolation — 400 ms on foot, 200 ms mounted/flying (`Movement.WalkFootDelay` / +`WalkMountDelay`) — is flagged as a run, or the client falls behind and snaps. An isolated +step (resuming after at least a walk interval standing) goes out as a walk regardless of +pace — the client renders each step alone, so a run-flagged single step darts — unless the +pace beats the run interpolation (a true sprinter), where a walk-rendered first step would +flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`, +`ApproachTarget`, `MoveToPoint`) take no run argument; to make a creature run, make it +fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just +taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace. + ### OnThink: the excess-call contract `OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index c5029131c..7d4581b20 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -474,6 +474,29 @@ The extra parameters (RangePerception, RangeFight, ActiveSpeed, PassiveSpeed) ha | `Name = "a creature"` in constructor | `public override string DefaultName => "a creature";` | | `get { return value; }` | `=> value;` expression-bodied | +## AI Movement: No `run` Argument + +RunUO's movement calls took a `run` flag that callers set inconsistently (`true` in +combat, `false` for pets, gated by `dist > 5` inside `MoveTo`). The flag only selects the +client's per-step animation time, so ModernUO derives it from the creature's step pace +(`BaseAI.ShouldRun`) and the parameter is gone: + +```csharp +// RunUO +MoveTo(combatant, true, m_Mobile.RangeFight); +WalkMobileRange(m_Mobile.ControlMaster, 1, false, 0, 1); + +// ModernUO +MoveTo(combatant, Mobile.RangeFight); +WalkMobileRange(Mobile.ControlMaster, 1, 0, 1); +``` + +`ApproachTarget`, `MoveToPoint` and `PathFollower.Follow` lose the argument the same way. +To make a creature run, make it fast (`SetMoveSpeed` / `npc-speeds.json`), not flagged. +An isolated step (after the creature stood for at least a walk interval) goes out as a +walk regardless of pace — only a continuing cadence, or a pace faster than the run +interpolation, flags run. + ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index 215dd3b06..358bf13c7 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -130,6 +130,9 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same | | `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` | | `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters | +| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) | +| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | +| `PathFollower.Follow(run, range)` | `Follow(range)` | Same | ## Networking From c9875e7f642ce505a5231e8dcfd58bfc955fc31b Mon Sep 17 00:00:00 2001 From: Sergi Rosell <50594106+srosellj@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:46:20 +0200 Subject: [PATCH 59/64] fix: delete the bonus item, not the primary yield, when the bonus cannot be placed (#2602) --- Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs index b82040134..b67665d04 100644 --- a/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs +++ b/Projects/UOContent/Engines/Harvest/Core/HarvestSystem.cs @@ -219,7 +219,7 @@ namespace Server.Engines.Harvest } else { - item.Delete(); + bonusItem?.Delete(); } } From 547c2ea0fa1acfcc1914e0805f25d1b48977454a Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:25:20 -0700 Subject: [PATCH 60/64] fix: Fixes tick count wrap-around in movement throttle, and eliminates more allocations in NetState (#2603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Removes the per-tick allocation in the movement throttle, fixes tick-count wrap-around bugs in the throttle and RTT probe state, and trims per-connection allocations and dead fields in `NetState`. ## Movement throttle - **No more per-tick `List` snapshot.** `ProcessAllQueues()` iterates the `HashSet` directly and removes drained or disconnected states in place. `HashSet.Remove` does not invalidate enumerators on .NET Core 3.0+ (verified on 10.0.11); only inserting a *new* member does, and the only `Add` is in the packet handler, which never nests with `Slice()`. The eager `Remove` calls in `RejectAndReset`, `ClearQueue`, and `ProcessMovementQueue` are gone; membership is reconciled once per tick from `_hasQueuedMovements`. - **Debug logging** is now gated solely by the per-connection `NetState.MovementLogging` flag. The global `movementThrottle.debugLogging` setting is removed. - **New settings**: `movementThrottle.maxRttBonus`, `movementThrottle.maxChainGap`, and `movementThrottle.speedHackNotificationCooldown` were fields with no config binding. ## Tick-count wrap-around All comparisons are now in subtraction form and no tick field uses zero as a sentinel: - `now < _nextMovementTime` in the queue drain loop → `now - _nextMovementTime < 0`. - `_lastMovementRecordTime > 0`, `_lastSpeedHackNotification`, `_rttProbeTime > 0`, and `_nextRttProbe == 0` sentinels replaced with `_hasMovementRecord`, `_speedHackNotified`, `_rttProbePending`, and a seeded `_nextRttProbe`. - `_lastQueueDepthCheck` and `_movementWindowStart` are seeded from `Core.TickCount` at construction and on reset instead of zero. User-visible effects of the old code: on hosts with pass-through counters (GCP) movement history never recorded and speed hack detection was silently off; on every host, staff speed hack notifications were suppressed until `Core.TickCount` exceeded the five-minute cooldown. ## NetState - `Instances` returns `HashSet` again so engine-internal `foreach` uses the struct enumerator instead of boxing through `IReadOnlySet`. - Removed `_sustainedQueueDepth` (declared and zeroed since #2266, never read), `_lastRtt` (now derived as `LastRtt` from the newest history slot), and `_rttProbeTimestampHiRes` (only fed one debug log line). 20 bytes per connection. - `HuePickers`, `Menus`, and `Trades` are lazily created instead of allocating three lists per connection, including every login-server connection that dies on shard select. `Trades` is released when it empties. All helpers and the `HuePickerResponse` / `MenuResponse` handlers are null-tolerant; the trade cancel loops keep their `i < Count` guards because `SecureTrade.Cancel()` runs virtual item hooks that can re-enter the same list. ## Testing - `dotnet build -c Release` clean. - All MovementThrottle tests pass (27), plus the Trade / Menu / HuePicker / NetState tests (32). --- Projects/Server/Mobiles/Mobile.cs | 2 +- Projects/Server/Network/MovementThrottle.cs | 138 ++++++++---------- .../Network/NetState/NetState.Movement.cs | 52 +++---- Projects/Server/Network/NetState/NetState.cs | 60 +++++++- .../Network/Packets/IncomingPlayerPackets.cs | 21 ++- 5 files changed, 153 insertions(+), 120 deletions(-) diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index fb028b9d7..208142225 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -1627,7 +1627,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player; - public bool HasTrade => m_NetState?.Trades.Count > 0; + public bool HasTrade => m_NetState?.Trades?.Count > 0; public bool NoMoveHS { get; set; } diff --git a/Projects/Server/Network/MovementThrottle.cs b/Projects/Server/Network/MovementThrottle.cs index c28ef2228..33ee318fc 100644 --- a/Projects/Server/Network/MovementThrottle.cs +++ b/Projects/Server/Network/MovementThrottle.cs @@ -50,9 +50,6 @@ public static class MovementThrottle private const int ClientMaxUnackedMovements = 5; private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 - // Debug logging - enable for testing speed hack detection - private static bool _debugLogging = false; - // Track NetStates with queued movements for efficient processing private static readonly HashSet _netStatesWithQueuedMovements = new(256); @@ -83,15 +80,9 @@ public static class MovementThrottle public static void Configure() { - _maxCredit = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.maxCredit", - _maxCredit - ); - - _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.hardQueueLimit", - _hardQueueLimit - ); + _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); + _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); + _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); _movementHistorySize = ServerConfiguration.GetOrUpdateSetting( "movementThrottle.movementHistorySize", @@ -103,6 +94,13 @@ public static class MovementThrottle _minSamplesForRate ); + _maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap); + + _speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting( + "movementThrottle.speedHackNotificationCooldown", + _speedHackNotificationCooldown + ); + _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( "movementThrottle.suspiciousRateThreshold", _suspiciousRateThreshold @@ -112,11 +110,6 @@ public static class MovementThrottle "movementThrottle.definiteRateThreshold", _definiteRateThreshold ); - - _debugLogging = ServerConfiguration.GetOrUpdateSetting( - "movementThrottle.debugLogging", - _debugLogging - ); } /// @@ -191,15 +184,16 @@ public static class MovementThrottle // Credit can go negative up to -dynamicCredit (debt limit) if (ns._movementCredit - earlyAmount >= -dynamicCredit) { - var prevCredit = ns._movementCredit; // Use credit to cover early arrival ns._movementCredit -= earlyAmount; - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { + var prevCredit = ns._movementCredit + earlyAmount; + logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit + mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit ); } @@ -208,11 +202,11 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", - mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit + mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit ); } @@ -227,11 +221,11 @@ public static class MovementThrottle var prevCredit = ns._movementCredit; ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); - if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) + if (ns._movementLogging && ns._movementCredit != prevCredit) { logger.Debug( "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", - mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit + mobile, delta, prevCredit, ns._movementCredit, dynamicCredit ); } } @@ -247,12 +241,9 @@ public static class MovementThrottle { if (!mobile.Move(dir)) { - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { - logger.Debug( - "[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", - mobile.RawName, dir, seq - ); + logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq); } // Movement failed (blocked, paralyzed, frozen, etc.) @@ -260,11 +251,11 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", - mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount + mobile, dir, seq, ns._nextMovementTime - Core.TickCount ); } @@ -304,11 +295,11 @@ public static class MovementThrottle ns._hasQueuedMovements = true; _netStatesWithQueuedMovements.Add(ns); - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", - ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count + ns.Mobile, dir, seq, ns._movementQueue.Count ); } } @@ -320,7 +311,6 @@ public static class MovementThrottle { ns.SendMovementRej(seq, mobile); ns.ResetMovementState(); - _netStatesWithQueuedMovements.Remove(ns); } /// @@ -333,20 +323,18 @@ public static class MovementThrottle return; } - // Process each NetState with queued movements - // Use a snapshot to avoid modification during iteration - var toProcess = new List(_netStatesWithQueuedMovements); - - for (var i = 0; i < toProcess.Count; i++) + foreach (var ns in _netStatesWithQueuedMovements) { - var ns = toProcess[i]; - if (!ns.Running) + if (ns.Running) { - _netStatesWithQueuedMovements.Remove(ns); - continue; + ProcessMovementQueue(ns); + if (ns._hasQueuedMovements) + { + continue; + } } - ProcessMovementQueue(ns); + _netStatesWithQueuedMovements.Remove(ns); } } @@ -356,6 +344,7 @@ public static class MovementThrottle public static void ProcessMovementQueue(NetState ns) { var mobile = ns.Mobile; + if (mobile?.Deleted != false) { ClearQueue(ns); @@ -374,7 +363,7 @@ public static class MovementThrottle while (ns._movementQueue?.Count > 0) { // Check if it's time to execute - if (now < ns._nextMovementTime) + if (now - ns._nextMovementTime < 0) { // Not yet - leave remaining items in queue for next Slice break; @@ -394,11 +383,11 @@ public static class MovementThrottle // Execute the move if (!mobile.Move(movement.Direction)) { - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { logger.Debug( "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", - mobile.RawName, movement.Direction, remaining + mobile, movement.Direction, remaining ); } @@ -407,12 +396,12 @@ public static class MovementThrottle return; } - if (_debugLogging && ns._movementLogging) + if (ns._movementLogging) { var waited = now - ns._nextMovementTime; logger.Debug( "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", - mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 + mobile, movement.Direction, remaining, waited >= 0 ? waited : 0 ); } @@ -430,10 +419,6 @@ public static class MovementThrottle // Update tracking ns._hasQueuedMovements = ns._movementQueue?.Count > 0; - if (!ns._hasQueuedMovements) - { - _netStatesWithQueuedMovements.Remove(ns); - } } /// @@ -469,7 +454,6 @@ public static class MovementThrottle { ns._movementQueue?.Clear(); ns._hasQueuedMovements = false; - _netStatesWithQueuedMovements.Remove(ns); } // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) @@ -484,7 +468,7 @@ public static class MovementThrottle logger.Information( "Movement queue overflow: {Character} ({Account}) | " + "Queue reached hard limit: {Limit} | IP: {IP}", - mobile?.RawName ?? "Unknown", + mobile, ns.Account?.Username ?? "Unknown", _hardQueueLimit, ns.Address @@ -516,7 +500,7 @@ public static class MovementThrottle private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) { // Calculate interval since last movement - var interval = ns._lastMovementRecordTime > 0 + var interval = ns._hasMovementRecord ? (int)(now - ns._lastMovementRecordTime) : -1; // -1 indicates first movement (no previous time) @@ -525,6 +509,7 @@ public static class MovementThrottle if (interval <= 0 || interval > _maxChainGap) { ns._lastMovementRecordTime = now; + ns._hasMovementRecord = true; // Use RTT to distinguish "stopped moving" vs "lagged" // - Stable low-latency connection with gap >> RTT → player stopped, reset history @@ -544,19 +529,19 @@ public static class MovementThrottle // A large gap followed by a burst of packets = likely lag recovery, not speed hack ns._lastGapDuration = interval; - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { var action = shouldReset ? "history reset" : "history preserved (possible lag)"; logger.Debug( "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "RTT={RTT}ms stable={Stable} → {Action})", - mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action + mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action ); } } - else if (_debugLogging && mobile?.RawName != null) + else if (ns._movementLogging) { - logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); + logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile); } return; @@ -572,12 +557,9 @@ public static class MovementThrottle // the next real move's interval artificially short, inflating rate. if (cost == 0) { - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { - logger.Debug( - "[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", - mobile.RawName - ); + logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile); } return; } @@ -613,15 +595,16 @@ public static class MovementThrottle } ns._lastMovementRecordTime = now; + ns._hasMovementRecord = true; // Debug logging - if (_debugLogging && mobile?.RawName != null) + if (ns._movementLogging) { var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; logger.Debug( "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", - mobile.RawName, interval, cost, record.QueueDepth, + mobile, interval, cost, record.QueueDepth, flags, historyCount, _movementHistorySize, ns.AverageRtt ); } @@ -814,7 +797,7 @@ public static class MovementThrottle var averageRtt = ns.AverageRtt; // Detailed rate breakdown for debugging - if (_debugLogging) + if (ns._movementLogging) { logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", rate, sampleCount, averageRtt); @@ -977,19 +960,19 @@ public static class MovementThrottle var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); // Debug logging - if (_debugLogging && ns.Mobile?.RawName != null) + if (ns._movementLogging) { var (burstSize, _) = DetectRecentBurst(ns); - var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; + var probeStatus = ns._rttProbePending ? "pending" : "idle"; var queueDepth = ns._movementQueue?.Count ?? 0; logger.Debug( "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", - ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds + ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ); logger.Debug( " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", - ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus + ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ); } @@ -1025,11 +1008,11 @@ public static class MovementThrottle if (shouldNotify) { - if (_debugLogging) + if (ns._movementLogging) { logger.Debug( "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", - urgency, ns.Mobile?.RawName, rate, verdict, confidence + urgency, ns.Mobile, rate, verdict, confidence ); } NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); @@ -1054,11 +1037,12 @@ public static class MovementThrottle var now = Core.TickCount; // Rate-limit notifications per player - if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) + if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) { return; } + ns._speedHackNotified = true; ns._lastSpeedHackNotification = now; var mobile = ns.Mobile; @@ -1070,7 +1054,7 @@ public static class MovementThrottle "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", urgency, - mobile?.RawName ?? "Unknown", + mobile, ns.Account?.Username ?? "Unknown", rate, sampleCount, @@ -1138,7 +1122,7 @@ public static class MovementThrottle Verdict = verdict, Confidence = confidence, AverageRtt = ns.AverageRtt, - LastRtt = ns._lastRtt, + LastRtt = ns.LastRtt, RttVariance = ns._rttVariance, StableConnection = ns.HasStableConnection, RttSampleCount = ns._rttSampleCount, diff --git a/Projects/Server/Network/NetState/NetState.Movement.cs b/Projects/Server/Network/NetState/NetState.Movement.cs index 1cc79429e..c6f28fcd1 100644 --- a/Projects/Server/Network/NetState/NetState.Movement.cs +++ b/Projects/Server/Network/NetState/NetState.Movement.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Runtime.InteropServices; using Server.Logging; @@ -70,23 +69,24 @@ public partial class NetState internal Queue _movementQueue; // Lazy initialized internal long _movementCredit; // Credit buffer for timing jitter internal long _nextMovementTime = Core.TickCount; // When next movement is allowed - internal int _sustainedQueueDepth; // Tracks sustained high queue depth - internal long _lastQueueDepthCheck; // Throttle depth check frequency + internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency internal bool _hasQueuedMovements; // Fast check for Slice() // Movement history for rate-based speed hack detection (lazy initialized) internal MovementRecord[] _movementHistory; // Circular buffer internal int _movementHistoryIndex; // Next write position (also serves as count until full) internal bool _movementHistoryFull; // True once buffer has wrapped - internal long _lastMovementRecordTime; // For calculating intervals + internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord) + internal bool _hasMovementRecord; // False until the first movement in a chain is seen // Detection state internal int _consecutiveHighRateSeconds; // Sustained detection counter - internal long _lastSpeedHackNotification; // Rate-limit notifications + internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) + internal bool _speedHackNotified; // False until the first notification is sent internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) // Movement packet rate tracking (for speed hack detection) - internal long _movementWindowStart; // Start of current 1-second window + internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window internal int _movementsInWindow; // Count in current window internal int _peakMovementRate; // Highest rate seen (packets/sec) @@ -100,10 +100,9 @@ public partial class NetState _nextMovementTime = Core.TickCount; _movementCredit = 0; _hasQueuedMovements = false; - _sustainedQueueDepth = 0; // Reset movement history - next movement starts a new chain - _lastMovementRecordTime = 0; + _hasMovementRecord = false; _movementHistoryIndex = 0; _movementHistoryFull = false; @@ -113,7 +112,7 @@ public partial class NetState _rttProbeInterval = RttProbeIntervalNormal; // Reset packet rate window - _movementWindowStart = 0; + _movementWindowStart = Core.TickCount; _movementsInWindow = 0; } @@ -165,17 +164,19 @@ public partial class NetState private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection // RTT state - internal long _rttProbeTime; // When we sent the probe (0 = not waiting) - internal long _lastRtt; // Most recent RTT measurement + internal bool _rttProbePending; // True while waiting for a probe response + internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) internal long[] _rttHistory; // Rolling history (lazy init) internal int _rttHistoryIndex; // Current position in history internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize) internal long _rttVariance; // Calculated variance for stability - internal long _nextRttProbe; // When to send next probe + internal long _nextRttProbe = Core.TickCount; // When to send next probe internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval - // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) - private long _rttProbeTimestampHiRes; + /// + /// Gets the most recent RTT measurement, or 0 if none has been recorded. + /// + public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0; /// /// Sets the RTT probe interval based on suspicion level. @@ -206,23 +207,22 @@ public partial class NetState var now = Core.TickCount; // Don't send if we're still waiting for a response - if (_rttProbeTime > 0) + if (_rttProbePending) { // Timeout after 10 seconds - connection is probably dead or very laggy if (now - _rttProbeTime > 10000) { - _rttProbeTime = 0; - _rttProbeTimestampHiRes = 0; + _rttProbePending = false; } return; } // First probe: send immediately when player starts moving // Subsequent probes: send when interval has passed - if (_nextRttProbe == 0 || now >= _nextRttProbe) + if (now - _nextRttProbe >= 0) { + _rttProbePending = true; _rttProbeTime = now; - _rttProbeTimestampHiRes = Stopwatch.GetTimestamp(); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); if (_movementLogging) @@ -242,10 +242,9 @@ public partial class NetState /// public void RecordRttMeasurement() { - var nowHiRes = Stopwatch.GetTimestamp(); var now = Core.TickCount; - if (_rttProbeTime <= 0) + if (!_rttProbePending) { // Not expecting a response (client-initiated version send) - ignore silently return; @@ -253,19 +252,15 @@ public partial class NetState var rtt = now - _rttProbeTime; - // High-resolution RTT in microseconds - var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency; - if (_movementLogging) { movementLogger.Debug( - "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)", - Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 + "[RTT-Response] {Account}: {Rtt}ms", + Account?.Username ?? _toString, rtt ); } - _rttProbeTime = 0; - _rttProbeTimestampHiRes = 0; + _rttProbePending = false; // Sanity check - RTT should be positive and reasonable if (rtt is <= 0 or > 10000) @@ -285,7 +280,6 @@ public partial class NetState // Update history _rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt; - _lastRtt = rtt; // Track sample count (saturates at buffer size) if (_rttSampleCount < RttHistorySize) diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 7312603f7..04f1a5a76 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -44,7 +44,7 @@ public partial class NetState : IComparable, IValueLinkListNode _connectingQueue = new(2048); private static readonly HashSet _instances = new(2048); - public static IReadOnlySet Instances => _instances; + public static HashSet Instances => _instances; private readonly string _toString; private ClientVersion _version; @@ -109,9 +109,6 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode Trades { get; } + public List Trades { get; private set; } public bool Seeded { get; set; } @@ -260,8 +257,18 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { + if (Trades == null) + { + break; + } + if (i >= Trades.Count) { continue; @@ -280,8 +287,18 @@ public partial class NetState : IComparable, IValueLinkListNode= 0; --i) { + if (Trades != null) + { + break; + } + if (i < Trades.Count) { Trades[i].Cancel(); @@ -291,11 +308,21 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode Date: Tue, 1 Sep 2026 20:42:15 -0700 Subject: [PATCH 61/64] feat: event-driven target acquisition with a reaction-time gradient (#2601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes walk-up aggro latency (up to a full 10 s of obliviousness) and hardens the reacquire gate so no state can silence acquisition, while turning `AcquireOnApproach` into the reaction-time knob for future per-creature intelligence tuning. ### Why `AcquireFocusMob` re-armed the 10 s `ReacquireDelay` **before** scanning, success or failure. A creature that scanned an empty room was blind for 10 s to a player walking up — walk-up aggro latency was uniform in 0..10 s. Waking from sector sleep stacked the AI timer's 0–3 s construction stagger on top. And `NextReacquireTime` is not serialized: on hosts whose tick counter starts negative (GCP pass-through), the 0 default blocked **all** acquisition shard-wide after a restart until the counter crossed zero. ### What **Event-driven reaction — `AcquireOnApproachDelay` (the intelligence gradient)** - The paragon `AcquireOnApproach` bool becomes a `TimeSpan` on every creature: an enemy moving inside `AcquireOnApproachRange` (10 for all creatures — on-screen reactive aggro; the periodic scan keeps the wide `RangePerception` sweep) *clamps* the next scan to at most the delay. Repeated steps cannot shorten it further — one scan per delay period, not per step or think. - `Zero` (paragons) also prods the AI timer: the ranked scan engages within a wheel turn — the old snap, minus the special-cased engage path. The target now comes from the normal FightMode ranking instead of whichever mobile happened to move, and the `Combatant == null` guard stops re-engage spam. - The 2 s default reads as "took a beat to notice you"; larger values are dumber; `ReacquireDelay` alone is the oblivious floor. Mover checks are the approach logic's `IsEnemy` + `CanBeHarmful` (so pets count and hidden movers are excluded via `CanSee`), with `IsEnemy` first to cheaply reject same-team wild creatures wandering past. The check rides the `OnMovement` callback every step already pays for — no polling added. **Gate correctness** - Every scan re-arms the full `ReacquireDelay`, success or failure (classic semantics; reaction time is the approach path, not the poll). - Self-healing by construction: a deadline further out than `ReacquireDelay` is an illegal state and reads as open — no wedged or wrapped value can silence acquisition beyond one delay period. - `NextReacquireTime` is seeded from a live tick on deserialize (the GCP negative-tick blackout). **AI timer wake** - Activation (sector wake, spawn, resurrection) starts within a 0–256 ms spread instead of the 0–3 s construction stagger, which read as lag. - The stagger's real job — keeping same-speed cohorts out of lock-step (the RunUO town artifact) — is now a zero-mean ±period/8 jitter on each **idle** think, so phases random-walk apart within seconds and can never re-lock. Instrumentation showed why a one-shot spread can't do this job: the timer wheel fires within ±1 ms, so with 10 creatures on a 500 ms period some pair collides on nearly the same phase ~75% of the time (birthday paradox) and then steps in the same loop iteration *forever*. Jitter is scoped to passive speed: engaged cadence stays exact, since pursuit timing anchors to real step times. **Debug** - The `AcquireFocusMob` scan message no longer re-arms the shared 5 s debug cooldown, which swallowed every AI's "I have detected X" transition line. **API change** for custom scripts: `AcquireOnApproach` (bool) → `AcquireOnApproachDelay` (TimeSpan). Documented in `content-patterns.md` § Target Acquisition, `runuo-migration-docs/09` + `11`, and the migration skill checklist. ### Tests `AcquisitionTests`: both scan outcomes honor `ReacquireDelay`; a 60 s-wedged gate still acquires; enemy movement clamps the deadline (same-team wild movers and out-of-range movers ignored); repeated movement cannot shorten below the delay; `Zero` opens the gate and prods without a direct engage. Full suite: 755 UOContent green. --- .../Tests/Mobiles/AI/AcquisitionTests.cs | 211 ++++++++++++++++++ Projects/UOContent/Mobiles/AI/ArcherAI.cs | 2 +- .../UOContent/Mobiles/AI/BaseAI/AITimer.cs | 23 +- .../UOContent/Mobiles/AI/BaseAI/BaseAI.cs | 24 +- Projects/UOContent/Mobiles/AI/BerserkAI.cs | 2 +- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 16 +- Projects/UOContent/Mobiles/BaseCreature.cs | 61 +++-- .../migrate-items-mobiles.md | 1 + .../modernuo-content-patterns.md | 4 +- dev-docs/content-patterns.md | 18 ++ .../09-items-mobiles-creatures.md | 20 ++ .../runuo-migration-docs/11-api-reference.md | 1 + 12 files changed, 340 insertions(+), 43 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs new file mode 100644 index 000000000..3ee516dcf --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/AcquisitionTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the +// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero = +// prodded scan); an illegal deadline self-heals. +[Collection("Sequential Pathfinding Tests")] +public class AcquisitionTests : IDisposable +{ + private readonly List _created = new(); + + public void Dispose() + { + foreach (var m in _created) + { + m?.Delete(); + } + + _created.Clear(); + } + + private sealed class WildStub : BaseCreature + { + public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + private sealed class TargetStub : Mobile + { + public TargetStub() => Body = 0x190; + } + + private WildStub Spawn(Map map, Point3D loc) + { + var bc = new WildStub(); + bc.MoveToWorld(loc, map); + bc.AIObject.AITimer?.Stop(); + _created.Add(bc); + return bc; + } + + [Fact] + public void EmptyScan_HonorsReacquireDelay() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + bc.NextReacquireTime = Core.TickCount; + + Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); + Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000); + } + + [Fact] + public void WedgedGate_SelfHeals() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + + var target = new TargetStub(); + target.DefaultMobileInit(); + target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map); + _created.Add(target); + + // Illegal deadline (beyond ReacquireDelay): must read as open, not block forever. + bc.NextReacquireTime = Core.TickCount + 60000; + + Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); + Assert.Equal(target, bc.FocusMob); + } + + [Theory] + [InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline + [InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored + [InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only + [InlineData(false, 20, false)] // outside approach range (10) is ignored + public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices) + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + bc.NextReacquireTime = Core.TickCount + 8000; + + Mobile mover; + if (wildMover) + { + mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z)); + } + else + { + mover = new TargetStub { Player = true }; + mover.DefaultMobileInit(); + mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map); + _created.Add(mover); + } + + bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); + + var remaining = bc.NextReacquireTime - Core.TickCount; + + if (notices) + { + // Clamped to the approach delay (2s), never opened outright. + Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds); + } + else + { + Assert.True(remaining > 5000); + } + } + + private sealed class InstantStub : BaseCreature + { + public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9; + + public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + } + + [Fact] + public void ZeroApproachDelay_OpensGateImmediately() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = new InstantStub(); + bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); + bc.AIObject.AITimer?.Stop(); + _created.Add(bc); + bc.NextReacquireTime = Core.TickCount + 8000; + + var mover = new TargetStub { Player = true }; + mover.DefaultMobileInit(); + mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); + _created.Add(mover); + + bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); + + // Zero = the gate opens and the AI is prodded to think now; no direct engage. + Assert.True(Core.TickCount - bc.NextReacquireTime >= 0); + Assert.Null(bc.Combatant); + Assert.True(bc.AIObject.AITimer.Running); + } + + [Fact] + public void RepeatedMovement_DoesNotShortenBelowApproachDelay() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + bc.NextReacquireTime = Core.TickCount + 8000; + + var mover = new TargetStub { Player = true }; + mover.DefaultMobileInit(); + mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map); + _created.Add(mover); + + bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z)); + var afterFirst = bc.NextReacquireTime; + + bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z)); + + Assert.Equal(afterFirst, bc.NextReacquireTime); + } + + [Fact] + public void SuccessfulAcquire_HoldsFullDelay() + { + var map = Map.Maps[1]; + Assert.NotNull(map); + map.GetAverageZ(1500, 1600, out _, out var z, out _); + + var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z)); + + var target = new TargetStub(); + target.DefaultMobileInit(); + target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map); + _created.Add(target); + + bc.NextReacquireTime = Core.TickCount; + + Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true)); + Assert.Equal(target, bc.FocusMob); + Assert.True(bc.NextReacquireTime - Core.TickCount > 5000); + } +} diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index 23ce0549c..803587ceb 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -17,7 +17,7 @@ public class ArcherAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index d5a84f94d..e11268e87 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -31,8 +31,8 @@ public sealed class AITimer : Timer private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; - public AITimer(BaseAI owner) : base(TimeSpan.FromMilliseconds(Utility.Random(3000)), - TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) + // The initial delay is irrelevant: Activate is the only start path and sets its own. + public AITimer(BaseAI owner) : base(TimeSpan.Zero, TimeSpan.FromSeconds(owner.Mobile.CurrentSpeed)) { _owner = owner; _owner._nextDetectHidden = Core.TickCount; @@ -48,7 +48,11 @@ public sealed class AITimer : Timer return; } - Start(); // keeps the stagger Delay + // Short random spread: the creature responds within a think while a sector's + // worth of timers avoids a same-tick burst; the idle think jitter keeps the + // cohort apart from there. + Delay = TimeSpan.FromMilliseconds(Utility.Random(256)); + Start(); _nextWake = Core.TickCount + (long)Delay.TotalMilliseconds; } @@ -148,7 +152,18 @@ public sealed class AITimer : Timer } // Cadence from the post-decision speed (decisions may flip active/passive). - _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + var period = (long)(_owner.Mobile.CurrentSpeed * 1000); + _nextThink = Core.TickCount + period; + + // Idle cadence drifts: a zero-mean jitter random-walks think phases apart, so + // creatures spawned or woken together cannot stay in lock-step (a one-shot + // spread can collide and identical periods never separate). Engaged cadence + // stays exact — pursuit timing anchors to real step times. + if (_owner.Mobile.CurrentSpeed == _owner.Mobile.PassiveSpeed) + { + var jitter = (int)(period >> 3); + _nextThink += Utility.RandomMinMax(-jitter, jitter); + } } else { diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 3c2a479e0..4b8753d10 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -850,22 +850,24 @@ public abstract partial class BaseAI return false; } - if (Core.TickCount - Mobile.NextReacquireTime < 0) + var reacquireDelay = (long)Mobile.ReacquireDelay.TotalMilliseconds; + var gateRemaining = Mobile.NextReacquireTime - Core.TickCount; + + if (gateRemaining > 0 && gateRemaining <= reacquireDelay) { Mobile.FocusMob = null; return false; } - Mobile.NextReacquireTime = Core.TickCount + (int)Mobile.ReacquireDelay.TotalMilliseconds; + DebugSay("Acquiring new target...", 0); - DebugSay("Acquiring new target..."); + var acquired = AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); - if (Mobile.Map == null) - { - return Mobile.FocusMob != null; - } + // Reaction time is the approach path (BaseCreature.ScheduleAcquireOnApproach), + // not this poll — every scan honors the full delay. + Mobile.NextReacquireTime = Core.TickCount + reacquireDelay; - return AcquireNewFocusMob(Mobile.Map, iRange, acqType, bPlayerOnly, bFacFriend, bFacFoe); + return acquired; } private bool HandleBardProvoked() @@ -941,8 +943,10 @@ public abstract partial class BaseAI private bool AcquireNewFocusMob(Map map, int iRange, FightMode acqType, bool bPlayerOnly, bool bFacFriend, bool bFacFoe) { - Mobile newFocusMob = null, enemySummonMob = null; - double val = double.MinValue, enemySummonVal = double.MinValue; + Mobile newFocusMob = null; + Mobile enemySummonMob = null; + var val = double.MinValue; + var enemySummonVal = double.MinValue; foreach (var m in map.GetMobilesInRange(Mobile.Location, iRange)) { diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index ff00ec91d..8a2015f1a 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -12,7 +12,7 @@ public class BerserkAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, FightMode.Closest, false, true, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name} and I will attack"); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index a71d83d5c..a6d0a9bf9 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace Server.Mobiles; public class MeleeAI : BaseAI @@ -14,6 +16,7 @@ public class MeleeAI : BaseAI if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } @@ -65,13 +68,9 @@ public class MeleeAI : BaseAI return true; } - private bool IsValidCombatant(Mobile combatant) - { - return combatant?.Deleted == false - && combatant.Map == Mobile.Map - && combatant.Alive - && !combatant.IsDeadBondedPet; - } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsValidCombatant(Mobile combatant) => + combatant?.Deleted == false && combatant.Map == Mobile.Map && combatant.Alive && !combatant.IsDeadBondedPet; private bool HandleOutOfRangeCombatant(Mobile combatant) { @@ -127,7 +126,8 @@ public class MeleeAI : BaseAI { if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { - this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking."); + this.DebugSayFormatted($"I have detected {Mobile.FocusMob.Name}, attacking"); + Mobile.Combatant = Mobile.FocusMob; Action = ActionType.Combat; } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 05c728cf4..581d472e3 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -936,21 +936,50 @@ namespace Server.Mobiles public virtual bool GivesMLMinorArtifact => false; - /* To save on cpu usage, RunUO creatures only reacquire creatures under the following circumstances: - * - 10 seconds have elapsed since the last time it tried - * - The creature was attacked - * - Some creatures, like dragons, will reacquire when they see someone move - * - * This functionality appears to be implemented on OSI as well - */ - public long NextReacquireTime { get; set; } public virtual TimeSpan ReacquireDelay => TimeSpan.FromSeconds(10.0); - public virtual bool ReacquireOnMovement => false; - public virtual bool AcquireOnApproach => m_Paragon; + + // Reaction-time gradient: an enemy moving inside AcquireOnApproachRange pulls the + // next scan to at most this far away. Zero (paragons) scans on the very next + // think; larger is dumber; pure ReacquireDelay is the oblivious floor. + public virtual TimeSpan AcquireOnApproachDelay => m_Paragon ? TimeSpan.Zero : TimeSpan.FromSeconds(2.0); + + // Reactive range is tighter than the periodic scan's RangePerception: approach + // aggro starts on-screen; the ReacquireDelay poll keeps the wide ambient sweep. public virtual int AcquireOnApproachRange => 10; + // Clamps the scan deadline rather than opening the gate: repeated steps cannot + // shorten it further, so an armed creature scans once per delay period. + private void ScheduleAcquireOnApproach() + { + var delay = (long)AcquireOnApproachDelay.TotalMilliseconds; + var deadline = Core.TickCount + delay; + + if (deadline - NextReacquireTime < 0) + { + NextReacquireTime = deadline; + } + + if (delay <= 0) + { + // Zero: think now — the ranked scan engages within a wheel turn. Prod is + // spam-safe; the Combatant == null guard stops the prods once engaged. + AIObject?.AITimer?.Prod(); + } + } + + // IsEnemy first — it cheaply rejects the common case (a same-team wild creature + // wandering past); CanBeHarmful covers hidden movers via CanSee. + private bool ShouldAcquireOnApproach(Mobile m) => + Combatant == null && + !Controlled && !Summoned && !BardPacified && + FightMode != FightMode.None && FightMode != FightMode.Aggressor && + InRange(m.Location, AcquireOnApproachRange) && + IsEnemy(m) && CanBeHarmful(m, false); + + public virtual bool ReacquireOnMovement => false; + public static bool Summoning { get; set; } public virtual bool IsDispellable => Summoned && !IsAnimatedDead; @@ -2024,6 +2053,8 @@ namespace Server.Mobiles { base.Deserialize(reader); + NextReacquireTime = Core.TickCount; + var version = reader.ReadInt(); m_CurrentAI = (AIType)reader.ReadInt(); @@ -2855,15 +2886,9 @@ namespace Server.Mobiles public override void OnMovement(Mobile m, Point3D oldLocation) { - if (AcquireOnApproach && !Controlled && !Summoned && !BardPacified && FightMode != FightMode.Aggressor) + if (ShouldAcquireOnApproach(m)) { - if (InRange(m.Location, AcquireOnApproachRange) && !InRange(oldLocation, AcquireOnApproachRange) && - CanBeHarmful(m) && IsEnemy(m)) - { - Combatant = FocusMob = m; - AIObject?.MoveTo(m, 1); - DoHarmful(m); - } + ScheduleAcquireOnApproach(); } else if (ReacquireOnMovement) { diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index ce70ad26c..f12f92c55 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -30,6 +30,7 @@ description: > - `Name = "text"` -> `public override string DefaultName => "text";` - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` - AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement +- `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 2b6df7d55..6e0902b64 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -29,7 +29,9 @@ description: > overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think AND clears move overrides, `SetMoveSpeed()` sets move only. The client `Running` bit is derived from the step pace (`BaseAI.ShouldRun`); movement APIs take no run argument -- - see `dev-docs/content-patterns.md` § Creature Speeds + see `dev-docs/content-patterns.md` § Creature Speeds. Reaction time to approaching + enemies is `AcquireOnApproachDelay` (TimeSpan gradient; `Zero` = paragon snap, 2s + default, `ReacquireDelay`-only = oblivious) -- see § Target Acquisition 8. **`OnThink` overrides must be excess-call tolerant** -- it fires more often than the think cadence (player commands prod it; speed-ups reschedule it). Gate consequential work on a tick-count deadline (subtraction form) or make it idempotent; bare per-call diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 3d11efb16..6205ec2d4 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -295,6 +295,24 @@ flood the client's step queue. Movement APIs (`MoveTo`, `WalkMobileRange`, fast. Creatures step at most once per `CurrentMoveSpeed` period, paced from the step just taken — a stall never banks catch-up steps, so a resumed chase restarts at full pace. +### Target Acquisition: the reaction-time gradient + +Acquisition is event-driven, not polled. The periodic scan (`AcquireFocusMob`) is gated by +`ReacquireDelay` (10 s default) and every scan re-arms it in full, success or failure — it +is target stickiness plus the fallback for what movement cannot signal (reveals, doors, +summons). Reaction time comes from `BaseCreature.OnMovement`: an enemy moving inside +`AcquireOnApproachRange` (10 — on-screen; the periodic scan keeps the wider +`RangePerception`) clamps the next scan to +at most **`AcquireOnApproachDelay`** — the intelligence gradient. `TimeSpan.Zero` +(paragons) also prods the AI, so the ranked scan engages within a timer-wheel turn; the +2 s default reads as "took a beat to notice you"; larger is dumber; a creature that +overrides the delay above `ReacquireDelay` is effectively oblivious to approach. Repeated +steps cannot shorten the clamp, so an armed creature scans once per delay period, not once +per step or think. `ReacquireOnMovement` remains the broader hook (any mover, no enemy +check, scan next think). The gate self-heals: a deadline further out than `ReacquireDelay` +is illegal and reads as open, so no wedged or wrapped value can silence acquisition beyond +one delay period. + ### OnThink: the excess-call contract `OnThink()` is a scheduler pass, not an action. The AI timer calls it *at least* at the diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index 7d4581b20..5b30e7ef9 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -497,6 +497,26 @@ An isolated step (after the creature stood for at least a walk interval) goes ou walk regardless of pace — only a continuing cadence, or a pace faster than the run interpolation, flags run. +## Target Acquisition: `AcquireOnApproach` Is a Delay + +RunUO's `AcquireOnApproach` bool (paragon insta-aggro on approach) is now +`AcquireOnApproachDelay`, a `TimeSpan` reaction-time gradient that applies to every +creature — enemy movement inside `AcquireOnApproachRange` schedules a scan within the +delay instead of waiting out the 10 s `ReacquireDelay` poll: + +```csharp +// RunUO +public override bool AcquireOnApproach => true; + +// ModernUO — Zero is the old instant behavior; larger values are dumber +public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero; +``` + +`AcquireOnApproachRange` stays 10 for all creatures (reactive aggro is on-screen; the +periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The +acquired target comes from the normal FightMode-ranked scan, not from whichever mobile +happened to move. See `content-patterns.md` § Target Acquisition. + ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index 358bf13c7..96db1590d 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -133,6 +133,7 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) | | `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | | `PathFollower.Follow(run, range)` | `Follow(range)` | Same | +| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior | ## Networking From 708a35433700152ee407c3acc8e2a7de67c9e800 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:23:32 -0700 Subject: [PATCH 62/64] perf: stop allocating stat/skill mod lists for every mobile (#2604) ## Summary `_statMods` and `_skillMods` are created lazily by `AddStatMod` / `AddSkillMod` and nulled when they empty, and every reader already null-checks. The eager `new List()` in `DefaultMobileInit` and `Deserialize` therefore allocated two dead 32-byte objects for every mobile. On a ~500k-mobile world that is ~32 MB and 1M gen2 objects that hold nothing. - Removes the four eager allocations. - Removes the `StatMods` accessor (no references). - Documents `SkillMods` as `null` when no mods are active (its one caller in `Skills.cs` already checks). First of three PRs from the lazy per-mobile collections design; `DamageEntries` and `Aggressors`/`Aggressed` follow separately. ## Breaking change - `Mobile.SkillMods` may now be `null` (it was never null after construction before). External callers that enumerate it or read `.Count` must null-check. - `Mobile.StatMods` is removed. Use `GetStatMod(name)` / `AddStatMod` / `RemoveStatMod`. Save format is untouched: neither list is serialized. ## Testing - `dotnet build -c Release` clean. - New `MobileLazyModListTests` plus full `Server.Tests` (840) and `UOContent.Tests` (756). --- Projects/Server/Mobiles/Mobile.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 208142225..0407d7f82 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -6478,9 +6478,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_DexLock = (StatLockType)reader.ReadByte(); m_IntLock = (StatLockType)reader.ReadByte(); - _statMods = new List(); - _skillMods = new List(); - if (version < 32) { if (reader.ReadBool()) @@ -7813,8 +7810,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro m_FollowersMax = 5; Skills = new Skills(this); Items = new List(); - _statMods = new List(); - _skillMods = new List(); Map = Map.Internal; AutoPageNotify = true; Aggressors = new List(); From e52d54b7dacaadca3f4051e6a741c28ce19df1ee Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:25:14 -0700 Subject: [PATCH 63/64] perf: keep damage entries in an inline intrusive list (#2605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `Mobile.DamageEntries` was a `List` allocated for every mobile, including the ~99% that never take damage. It is now an inline `ValueLinkList` (24 bytes in the `Mobile` object, no separate allocation) ordered least recent → most recent. - `DamageEntry` implements `IValueLinkListNode`. - `RegisterDamage` moves the entry to the tail in O(1) instead of `Remove` + `Add` on a list. - Expired entries are always a head prefix, so pruning walks from the head and stops at the first live entry. The `DamageEntries` getter prunes on access. - `DamageEntries` is exposed as `ref readonly`; enumerate with `foreach` or `.ByDescending()`. Mutation goes through `RegisterDamage` / `ClearDamageEntries`. - `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` take `in ValueLinkList`; all callers compile unchanged. Files that `foreach` over `DamageEntries` need `using Server.Collections;` for the enumerator extension. - RunUO migration docs (`dev-docs/runuo-migration-docs/09`, `11`) and the `migrate-items-mobiles` skill document the change. Saves one object and 16 bytes per mobile (~8 MB and 500k gen2 objects on a 500k world). Second of three PRs from the lazy per-mobile collections design (first: #2604). Branched from `main`; the two diffs touch disjoint hunks of `Mobile.cs`. ## Breaking change - `Mobile.DamageEntries` is no longer a `List`. Indexing, `.Clear()`, `.Add()`, `.Remove()` no longer compile; use `foreach`, `.ByDescending()`, `.Count`, `ClearDamageEntries()`, and `RegisterDamage`. Calling a `ValueLinkList` mutator on the `ref readonly` property compiles but operates on a copy while still unlinking the real nodes; do not. - `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` signatures changed to `(in ValueLinkList, …)`. Save format is untouched: damage entries are not serialized. ## Behavior Recency order, `allowSelf`, tie-breaking in `FindMostTotal`/`FindLeastTotal` (most recent wins), `Responsible` accounting, and loot-rights ordering are unchanged and covered by the new `DamageEntryTests` and `LootingRightsTests`. ## Testing - `dotnet build -c Release` clean. - New `DamageEntryTests` and `LootingRightsTests` plus full `Server.Tests` and `UOContent.Tests`. --- .../Tests/Mobiles/DamageEntryTests.cs | 311 ++++++++++++++++++ Projects/Server/Mobiles/Mobile.cs | 174 +++++----- .../Tests/Mobiles/LootingRightsTests.cs | 146 ++++++++ .../Engines/CannedEvil/ChampionSpawn.cs | 6 +- Projects/UOContent/Mobiles/BaseCreature.cs | 25 +- .../UOContent/Mobiles/Special/Harrower.cs | 1 + .../migrate-items-mobiles.md | 1 + .../09-items-mobiles-creatures.md | 39 +++ .../runuo-migration-docs/11-api-reference.md | 3 + 9 files changed, 603 insertions(+), 103 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs create mode 100644 Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs diff --git a/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs new file mode 100644 index 000000000..9c3cdfd14 --- /dev/null +++ b/Projects/Server.Tests/Tests/Mobiles/DamageEntryTests.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using Server.Collections; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class DamageEntryTests +{ + private class TestMobile : Mobile + { + } + + private class PetMobile : Mobile + { + public Mobile Master { get; set; } + + public override Mobile GetDamageMaster(Mobile damagee) => Master; + } + + private static List Damagers(Mobile victim) + { + var result = new List(); + foreach (var de in victim.DamageEntries) + { + result.Add(de.Damager); + } + + return result; + } + + [Fact] + public void FreshMobile_HasNoEntries() + { + var m = new TestMobile(); + + try + { + Assert.Equal(0, m.DamageEntries.Count); + Assert.Null(m.FindMostRecentDamageEntry(true)); + Assert.Null(m.FindLeastRecentDamageEntry(true)); + Assert.Null(m.FindMostTotalDamageEntry(true)); + Assert.Null(m.FindLeastTotalDamageEntry(true)); + Assert.Null(m.FindDamageEntryFor(m)); + } + finally + { + m.Delete(); + } + } + + [Fact] + public void RegisterDamage_OrdersLeastRecentToMostRecent() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + victim.RegisterDamage(20, b); + victim.RegisterDamage(5, a); // a becomes most recent again + + Assert.Equal(2, victim.DamageEntries.Count); + Assert.Equal(new[] { b, a }, Damagers(victim)); + Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven); + Assert.Same(a, victim.FindMostRecentDamager(true)); + Assert.Same(b, victim.FindLeastRecentDamager(true)); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void FindRecent_HonorsAllowSelf() + { + var victim = new TestMobile(); + var a = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + victim.RegisterDamage(10, victim); // self is most recent + + Assert.Same(victim, victim.FindMostRecentDamager(true)); + Assert.Same(a, victim.FindMostRecentDamager(false)); + Assert.Same(a, victim.FindLeastRecentDamager(false)); + } + finally + { + victim.Delete(); + a.Delete(); + } + } + + [Fact] + public void FindLeastRecent_HonorsAllowSelf() + { + var victim = new TestMobile(); + var a = new TestMobile(); + + try + { + victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip + victim.RegisterDamage(10, a); + + Assert.Same(victim, victim.FindLeastRecentDamager(true)); + Assert.Same(a, victim.FindLeastRecentDamager(false)); + } + finally + { + victim.Delete(); + a.Delete(); + } + } + + [Fact] + public void FindTotal_PicksByDamage_MostRecentWinsTies() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + var c = new TestMobile(); + + try + { + victim.RegisterDamage(30, a); + victim.RegisterDamage(30, b); // ties a; b is more recent + victim.RegisterDamage(1, c); + + Assert.Same(b, victim.FindMostTotalDamager(true)); + Assert.Same(c, victim.FindLeastTotalDamager(true)); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + c.Delete(); + } + } + + [Fact] + public void FindLeastTotal_MostRecentWinsTies() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + var c = new TestMobile(); + + try + { + victim.RegisterDamage(30, a); + victim.RegisterDamage(5, b); + victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent + + Assert.Same(a, victim.FindMostTotalDamager(true)); + Assert.Same(c, victim.FindLeastTotalDamager(true)); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + c.Delete(); + } + } + + [Fact] + public void Prune_RemovesExpiredPrefix_KeepsOrder() + { + var start = Core._now; + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + + Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1); + victim.RegisterDamage(10, b); // a is now expired, b is live + + Assert.Equal(new[] { b }, Damagers(victim)); + Assert.Null(victim.FindDamageEntryFor(a)); + } + finally + { + Core._now = start; + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void Prune_AllExpired_EmptiesList() + { + var start = Core._now; + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + victim.RegisterDamage(10, a); + victim.RegisterDamage(10, b); + + Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1); + + Assert.Equal(0, victim.DamageEntries.Count); + Assert.Null(victim.FindMostRecentDamageEntry(true)); + } + finally + { + Core._now = start; + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void ClearDamageEntries_UnlinksEveryNode() + { + var victim = new TestMobile(); + var a = new TestMobile(); + var b = new TestMobile(); + + try + { + var ea = victim.RegisterDamage(10, a); + var eb = victim.RegisterDamage(10, b); + + victim.ClearDamageEntries(); + + Assert.Equal(0, victim.DamageEntries.Count); + Assert.False(ea.OnLinkList); + Assert.False(eb.OnLinkList); + Assert.Null(ea.Next); + Assert.Null(ea.Previous); + Assert.Null(eb.Next); + Assert.Null(eb.Previous); + } + finally + { + victim.Delete(); + a.Delete(); + b.Delete(); + } + } + + [Fact] + public void FullHitPoints_ClearsEntries() + { + var victim = new TestMobile(); + var a = new TestMobile(); + + try + { + victim.RawStr = 50; // HitsMax follows Str for a base Mobile + victim.Hits = 10; + victim.RegisterDamage(10, a); + Assert.Equal(1, victim.DamageEntries.Count); + + // Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind. + victim.Hits = victim.HitsMax; + + Assert.Equal(0, victim.DamageEntries.Count); + } + finally + { + victim.Delete(); + a.Delete(); + } + } + + [Fact] + public void RegisterDamage_AccumulatesResponsibleMaster() + { + var victim = new TestMobile(); + var master = new TestMobile(); + var pet = new PetMobile { Master = master }; + + try + { + victim.RegisterDamage(10, pet); + var entry = victim.RegisterDamage(5, pet); + + Assert.Same(pet, entry.Damager); + Assert.Equal(15, entry.DamageGiven); + Assert.NotNull(entry.Responsible); + Assert.Single(entry.Responsible); + Assert.Same(master, entry.Responsible[0].Damager); + Assert.Equal(15, entry.Responsible[0].DamageGiven); + Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list + } + finally + { + victim.Delete(); + master.Delete(); + pet.Delete(); + } + } +} diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 0407d7f82..d1db8113b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text); public delegate void PromptStateCallback(Mobile from, string text, T state); -public class DamageEntry +public class DamageEntry : IValueLinkListNode { public DamageEntry(Mobile damager) => Damager = damager; @@ -57,6 +57,11 @@ public class DamageEntry public List Responsible { get; set; } public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0); + + // Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list. + public DamageEntry Next { get; set; } + public DamageEntry Previous { get; set; } + public bool OnLinkList { get; set; } } [Flags] @@ -377,7 +382,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors = new List(); Aggressed = new List(); NextSkillTime = Core.TickCount; - DamageEntries = new List(); } // Sectors @@ -958,7 +962,23 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static VisibleDamageType VisibleDamageType { get; set; } - public List DamageEntries { get; private set; } + private ValueLinkList _damageEntries; + + /// + /// Damage entries ordered least recent (head) to most recent (tail). Expired entries are + /// pruned on access. Enumerate with foreach (ascending) or .ByDescending(). + /// Mutate only through and . + /// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy + /// while still unlinking the real nodes — it silently corrupts the list. + /// + public ref readonly ValueLinkList DamageEntries + { + get + { + PruneExpiredDamageEntries(); + return ref _damageEntries; + } + } [CommandProperty(AccessLevel.GameMaster)] public Mobile LastKiller { get; set; } @@ -2020,10 +2040,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro Aggressors[i].CanReportMurder = false; } - if (DamageEntries.Count > 0) - { - DamageEntries.Clear(); // reset damage entries on full HP - } + ClearDamageEntries(); // reset damage entries on full HP } else if (CanRegenHits) { @@ -5745,24 +5762,54 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } + // Entries are kept in LastDamage order, so expired entries are always a head prefix. + private void PruneExpiredDamageEntries() + { +#if DEBUG + for (var node = _damageEntries._first; node != null; node = node.Next) + { + Debug.Assert( + node.Next == null || node.Next.LastDamage >= node.LastDamage, + "Damage entries must be ordered by LastDamage ascending." + ); + } +#endif + + var first = _damageEntries._first; + + if (first?.HasExpired != true) + { + return; + } + + var firstLive = first.Next; + + while (firstLive?.HasExpired == true) + { + firstLive = firstLive.Next; + } + + if (firstLive == null) + { + _damageEntries.RemoveAll(); + } + else + { + _damageEntries.RemoveAllBefore(firstLive); + } + } + + public void ClearDamageEntries() => _damageEntries.RemoveAll(); + public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager; public DamageEntry FindMostRecentDamageEntry(bool allowSelf) { - for (var i = DamageEntries.Count - 1; i >= 0; --i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if (allowSelf || de.Damager != this) + if (allowSelf || de.Damager != this) { return de; } @@ -5775,21 +5822,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) { - for (var i = 0; i < DamageEntries.Count; ++i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._first; de != null; de = de.Next) { - if (i < 0) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - --i; - } - else if (allowSelf || de.Damager != this) + if (allowSelf || de.Damager != this) { return de; } @@ -5800,24 +5837,17 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; + // Walks most recent first with a strict comparison so the most recent entry wins ties, + // matching the previous reverse-indexed loop. public DamageEntry FindMostTotalDamageEntry(bool allowSelf) { + PruneExpiredDamageEntries(); + DamageEntry mostTotal = null; - for (var i = DamageEntries.Count - 1; i >= 0; --i) + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) + if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) { mostTotal = de; } @@ -5830,46 +5860,28 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) { - DamageEntry mostTotal = null; + PruneExpiredDamageEntries(); - for (var i = DamageEntries.Count - 1; i >= 0; --i) + DamageEntry leastTotal = null; + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) + if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven)) - { - mostTotal = de; + leastTotal = de; } } - return mostTotal; + return leastTotal; } public DamageEntry FindDamageEntryFor(Mobile m) { - for (var i = DamageEntries.Count - 1; i >= 0; --i) + PruneExpiredDamageEntries(); + + for (var de = _damageEntries._last; de != null; de = de.Previous) { - if (i >= DamageEntries.Count) - { - continue; - } - - var de = DamageEntries[i]; - - if (de.HasExpired) - { - DamageEntries.RemoveAt(i); - } - else if (de.Damager == m) + if (de.Damager == m) { return de; } @@ -5887,8 +5899,13 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro de.DamageGiven += amount; de.LastDamage = Core.Now; - DamageEntries.Remove(de); - DamageEntries.Add(de); + // Move to the tail so the list stays in LastDamage order. + if (de.OnLinkList) + { + _damageEntries.Remove(de); + } + + _damageEntries.AddLast(de); var master = from.GetDamageMaster(this); @@ -7814,7 +7831,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro AutoPageNotify = true; Aggressors = new List(); Aggressed = new List(); - DamageEntries = new List(); NextSkillTime = Core.TickCount; } diff --git a/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs new file mode 100644 index 000000000..e02abece7 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/LootingRightsTests.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using Server.Mobiles; +using Xunit; + +namespace Server.Tests; + +/// +/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the +/// returned stores are sorted by damage descending, the first (least recent) damager takes the +/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited +/// to its damage master rather than to the pet. +/// +[Collection("Sequential UOContent Tests")] +public class LootingRightsTests +{ + private class TestMobile : Mobile + { + } + + private class PetMobile : Mobile + { + public Mobile Master { get; set; } + + public override Mobile GetDamageMaster(Mobile damagee) => Master; + } + + // GetLootingRights only ever credits mobiles flagged as players. + private static TestMobile NewPlayer() => new() { Player = true }; + + private static DamageStore FindStore(List rights, Mobile m) + { + for (var i = 0; i < rights.Count; i++) + { + if (rights[i].m_Mobile == m) + { + return rights[i]; + } + } + + return null; + } + + [Fact] + public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus() + { + var victim = new TestMobile(); + var first = NewPlayer(); + var second = NewPlayer(); + + try + { + victim.RegisterDamage(100, first); + victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager" + + // hitsMax < 200 puts the bar at topDamage / 2. + var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100); + + Assert.Equal(2, rights.Count); + + // Sorted by damage descending. + Assert.True(rights[0].m_Damage >= rights[1].m_Damage); + Assert.Same(first, rights[0].m_Mobile); + Assert.Same(second, rights[1].m_Mobile); + + // The first damager - the least recent entry - gets the 1.25x bonus; nobody else does. + Assert.Equal(125, rights[0].m_Damage); + Assert.Equal(40, rights[1].m_Damage); + + // topDamage 125 / 2 = 62, so 40 is below the bar. + Assert.True(rights[0].m_HasRight); + Assert.False(rights[1].m_HasRight); + } + finally + { + victim.Delete(); + first.Delete(); + second.Delete(); + } + } + + [Fact] + public void HitsMaxBand_MovesTheRightsThreshold() + { + var victim = new TestMobile(); + var first = NewPlayer(); + var second = NewPlayer(); + + try + { + victim.RegisterDamage(100, first); + victim.RegisterDamage(40, second); + + // hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears. + var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200); + + Assert.Equal(2, rights.Count); + Assert.True(rights[0].m_HasRight); + Assert.True(rights[1].m_HasRight); + Assert.Same(second, rights[1].m_Mobile); + } + finally + { + victim.Delete(); + first.Delete(); + second.Delete(); + } + } + + [Fact] + public void PetDamage_CreditsTheMaster_NotThePet() + { + var victim = new TestMobile(); + var master = NewPlayer(); + var pet = new PetMobile { Master = master }; + var wild = new TestMobile(); // no damage master, and not a player + + try + { + victim.RegisterDamage(50, pet); + victim.RegisterDamage(20, wild); + + var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100); + + // The master is credited through the entry's Responsible sub-entry, and is the only one. + Assert.Single(rights); + + var masterStore = FindStore(rights, master); + Assert.NotNull(masterStore); + Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus + Assert.True(masterStore.m_HasRight); + + // The pet's own damage was fully handed to the master, so it earns no store. + Assert.Null(FindStore(rights, pet)); + + // A non-player damager earns nothing even when its damage was never reassigned. + Assert.Null(FindStore(rights, wild)); + } + finally + { + victim.Delete(); + master.Delete(); + pet.Delete(); + wild.Delete(); + } + } +} diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 9daea0ba3..3a024576b 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -18,6 +18,7 @@ using System.Net; using System.Collections.Generic; using System.Runtime.InteropServices; using ModernUO.Serialization; +using Server.Collections; using Server.Engines.Virtues; using Server.Gumps; using Server.Items; @@ -1181,11 +1182,6 @@ public partial class ChampionSpawn : Item foreach (var de in m.DamageEntries) { - if (de.HasExpired) - { - continue; - } - var damager = de.Damager; var master = damager.GetDamageMaster(m); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index 581d472e3..404aaede8 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -3123,14 +3123,12 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - public int ComputeBonusDamage(List list, Mobile m) + public int ComputeBonusDamage(in ValueLinkList list, Mobile m) { var bonus = 0; - for (var i = list.Count - 1; i >= 0; --i) + foreach (var de in list.ByDescending()) { - var de = list[i]; - if (de.Damager == m || de.Damager is not BaseCreature bc) { continue; @@ -3167,26 +3165,15 @@ namespace Server.Mobiles Combatant is PlayerMobile || Combatant is BaseCreature { Controlled: true } bc && bc.GetMaster() is PlayerMobile; - public static List GetLootingRights(List damageEntries, int hitsMax) + // Iterates most recent first, matching the previous reverse-indexed loop. The list is + // already pruned of expired entries by the Mobile.DamageEntries getter. + public static List GetLootingRights(in ValueLinkList damageEntries, int hitsMax) { var rights = new List(); DamageStore firstDamager = null; - for (var i = damageEntries.Count - 1; i >= 0; --i) + foreach (var de in damageEntries.ByDescending()) { - if (i >= damageEntries.Count) - { - continue; - } - - var de = damageEntries[i]; - - if (de.HasExpired) - { - damageEntries.RemoveAt(i); - continue; - } - var damage = de.DamageGiven; var respList = de.Responsible; diff --git a/Projects/UOContent/Mobiles/Special/Harrower.cs b/Projects/UOContent/Mobiles/Special/Harrower.cs index ee3fa1873..8d87943bc 100644 --- a/Projects/UOContent/Mobiles/Special/Harrower.cs +++ b/Projects/UOContent/Mobiles/Special/Harrower.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ModernUO.Serialization; +using Server.Collections; using Server.Engines.CannedEvil; using Server.Engines.Virtues; using Server.Items; diff --git a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md index f12f92c55..7310c104e 100644 --- a/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md +++ b/dev-docs/claude-skills/migrate-from-runuo/migrate-items-mobiles.md @@ -31,6 +31,7 @@ description: > - Expression-bodied overrides: `public override int Meat { get { return 1; } }` -> `public override int Meat => 1;` - AI movement calls lose the `run` flag: `MoveTo(m, true, range)` -> `MoveTo(m, range)` (also `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `PathFollower.Follow`); the Running bit is derived from step pace -> `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § AI Movement - `AcquireOnApproach` (bool) -> `AcquireOnApproachDelay` (TimeSpan; `Zero` = old instant behavior) -> same doc § Target Acquisition +- `DamageEntries` is an inline `ref readonly ValueLinkList`, not a `List`: indexer/`Add`/`Remove`/`Clear` -> `foreach` / `.ByDescending()` (needs `using Server.Collections;`) and `ClearDamageEntries()`; `GetLootingRights` takes it by `in` -> same doc § Damage Entries ## Anti-Patterns - Using `_field--` instead of `Property--` (bypasses MarkDirty tracking) diff --git a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md index 5b30e7ef9..def2ecf48 100644 --- a/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md +++ b/dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md @@ -517,6 +517,45 @@ periodic `ReacquireDelay` scan still sweeps the full `RangePerception`). The acquired target comes from the normal FightMode-ranked scan, not from whichever mobile happened to move. See `content-patterns.md` § Target Acquisition. +## Damage Entries: Inline `ValueLinkList`, Not `List` + +RunUO's `Mobile.DamageEntries` was a `List` allocated for every mobile. +ModernUO keeps damage entries in an inline `ValueLinkList` struct held by +the mobile itself, ordered least recent → most recent, so a mobile that never takes +damage owns no list object and `RegisterDamage` relinks in O(1). The property is +`ref readonly`; expired entries are pruned when it is read. + +```csharp +// RunUO +for (var i = m.DamageEntries.Count - 1; i >= 0; --i) +{ + var de = m.DamageEntries[i]; // indexer + ... +} +m.DamageEntries.Clear(); +var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // List + +// ModernUO — needs `using Server.Collections;` for the enumerator extensions +foreach (var de in m.DamageEntries.ByDescending()) // most recent first +{ + ... +} +foreach (var de in m.DamageEntries) // least recent first +{ + ... +} +m.ClearDamageEntries(); +var rights = BaseCreature.GetLootingRights(m.DamageEntries, m.HitsMax); // in ValueLinkList +``` + +What no longer compiles: the indexer, `.Add`, `.Remove`, `.RemoveAt`, `.Clear`, and +passing the property where a `List` is expected. `.Count`, +`FindDamageEntryFor`, `FindMostRecentDamager` and the other `Find*` methods, and +`RegisterDamage` are unchanged. `DamageEntry` now carries `Next`/`Previous`/`OnLinkList` +link fields; never set them yourself, and never call a `ValueLinkList` mutator on the +`ref readonly` property — it compiles against a copy and corrupts the node's link state. +Mutate only through `RegisterDamage` and `ClearDamageEntries`. + ## Item Name Changes ```csharp diff --git a/dev-docs/runuo-migration-docs/11-api-reference.md b/dev-docs/runuo-migration-docs/11-api-reference.md index 96db1590d..b6c12bfe8 100644 --- a/dev-docs/runuo-migration-docs/11-api-reference.md +++ b/dev-docs/runuo-migration-docs/11-api-reference.md @@ -134,6 +134,9 @@ Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search. | `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same | | `PathFollower.Follow(run, range)` | `Follow(range)` | Same | | `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior | +| `m.DamageEntries` (`List`) | `m.DamageEntries` (`ref readonly ValueLinkList`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` | +| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | | +| `GetLootingRights(List, int)` | `GetLootingRights(in ValueLinkList, int)` | Callers passing `m.DamageEntries` compile unchanged | ## Networking From 25a2aa03c5cbe608eeed61ceaad02f5e6dc02d7e Mon Sep 17 00:00:00 2001 From: WarrentyExpired Date: Wed, 2 Sep 2026 10:16:24 -0400 Subject: [PATCH 64/64] #W# Update: added Distribution/Data/Files to gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d5b26e268..d29ee313a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Distribution Files +/Distribution/Data/Files /Distribution/Logger /Distribution/Logger.* /Distribution/ModernUO